diff --git a/CLAUDE.md b/CLAUDE.md
index d82acc5..0086775 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,9 +61,9 @@ CommunityFix exposes an MCP (Model Context Protocol) server at `POST /api/mcp` s
- Access tokens are opaque, valid 1h, sha256-hashed in `oauth_tokens`. Refresh tokens rotate on every use; presenting an already-rotated refresh token triggers **reuse detection** — the whole token family for that client+user is revoked.
- **Rate limiting:** KV-backed fixed-window limiter (`server/utils/rate-limit.ts`, counters in the `auth` KV namespace with a per-window TTL so KV evicts them — no purge needed; **fails open** if KV is unavailable so a KV blip can't take down auth/MCP). Per-IP on `/oauth/register` (10/h), `/oauth/token` + `/oauth/revoke` (60/5min); per-user on `/api/mcp` (300/min global, 40/10min writes, 60/min embedding-search tools). A single JSON-RPC request is capped at 50 batched calls (`MAX_BATCH`) so a giant batch of unmetered read tools can't bypass the global window.
- **Cleanup:** `purgeExpired()` reaps expired codes and dead token rows (kept until the refresh window closes so reuse detection works). Run by the `oauth:purge` scheduled task (`nitro.scheduledTasks`, 3:15am UTC).
-- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (authoring guides — no `slug` lists them, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection `content/guide/*.md` via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown).
+- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `propose_edit` / `list_revisions` / `review_revision` (wiki-style editing — non-owner edits land as pending revision proposals, `applied: true/false` in the response), `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (the single **authoring guide** `content/guide/authoring.md`, slug `authoring` — instruction-style, covers modeling/writing/evidence/MCP etiquette for humans and AI agents; no `slug` lists guides, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown).
- **Dedup is advisory, not enforced:** there is no server-side duplicate gate. Server instructions + tool descriptions tell clients to `search_issues_solutions` before creating and to update the closest match instead of adding a near-duplicate, but `create_*` will create whatever it is asked to.
-- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Writes log a `[mcp.audit]` line with the acting `clientId`.
+- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Write tools accept an optional `model` field (self-reported model id of the AI client); writes log a `[mcp.audit]` line with the acting `clientId` and `model`.
- Tool business logic lives in `server/utils/mcp-tools.ts`; the JSON-RPC plumbing in `server/api/mcp/index.post.ts`
- Tables: `oauth_clients`, `oauth_codes`, `oauth_tokens` (migration `0007_cool_peter_quill.sql`); `resource` audience columns on `oauth_codes`/`oauth_tokens` (migration `0016_resource_audience_binding.sql`)
diff --git a/app/assets/css/leaflet.css b/app/assets/css/leaflet.css
new file mode 100644
index 0000000..a05f26c
--- /dev/null
+++ b/app/assets/css/leaflet.css
@@ -0,0 +1,30 @@
+/* Leaflet control overrides shared by every map component (ExploreMap,
+ LocationMap, LocationPicker). Leaflet renders its controls with global
+ class names, so these live here instead of per-component scoped copies. */
+.leaflet-control-zoom {
+ border: none !important;
+ border-radius: .5rem !important;
+ overflow: hidden !important;
+ box-shadow: 0 2px 8px #0000001f !important;
+}
+
+.leaflet-control-zoom a {
+ background: #fff !important;
+ color: #6b7280 !important;
+ border: none !important;
+ border-bottom: 1px solid #f3f4f6 !important;
+ width: 32px !important;
+ height: 32px !important;
+ line-height: 32px !important;
+ font-size: 16px !important;
+ transition: background .15s !important;
+}
+
+.leaflet-control-zoom a:last-child {
+ border-bottom: none !important;
+}
+
+.leaflet-control-zoom a:hover {
+ background: #f9fafb !important;
+ color: #374151 !important;
+}
diff --git a/app/components/BanNotice.vue b/app/components/BanNotice.vue
index 50066cf..e98f1f2 100644
--- a/app/components/BanNotice.vue
+++ b/app/components/BanNotice.vue
@@ -13,13 +13,14 @@ const emit = defineEmits<{
}>()
const toast = useToast()
+const { track } = useUmami()
const submitting = ref(false)
async function submitAppeal() {
submitting.value = true
try {
await $fetch('/api/user/ban-appeal', { method: 'POST' })
- umami.track('Ban appeal submitted')
+ track('Ban appeal submitted')
toast.add({
title: 'Appeal submitted',
description: 'Your appeal is under review.',
@@ -60,13 +61,7 @@ async function submitAppeal() {
-
+
Appeal this ban
diff --git a/app/components/ExploreMap.vue b/app/components/ExploreMap.vue
new file mode 100644
index 0000000..bcad313
--- /dev/null
+++ b/app/components/ExploreMap.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
diff --git a/app/components/LocationMap.vue b/app/components/LocationMap.vue
index 985ac52..a651dea 100644
--- a/app/components/LocationMap.vue
+++ b/app/components/LocationMap.vue
@@ -175,35 +175,9 @@ defineExpose({
diff --git a/app/components/LocationPicker.vue b/app/components/LocationPicker.vue
index 749cabe..229a9ff 100644
--- a/app/components/LocationPicker.vue
+++ b/app/components/LocationPicker.vue
@@ -377,32 +377,5 @@ onBeforeUnmount(() => {
transform: rotate(-45deg);
box-shadow: 0 3px 10px #00000040;
}
-/* Leaflet zoom control overrides */
-:deep(.leaflet-control-zoom) {
- border: none !important;
- border-radius: .5rem !important;
- overflow: hidden !important;
- box-shadow: 0 2px 8px #0000001f !important;
-}
-
-:deep(.leaflet-control-zoom a) {
- background: #fff !important;
- color: #6b7280 !important;
- border: none !important;
- border-bottom: 1px solid #f3f4f6 !important;
- width: 32px !important;
- height: 32px !important;
- line-height: 32px !important;
- font-size: 16px !important;
- transition: background .15s !important;
-}
-
-:deep(.leaflet-control-zoom a:last-child) {
- border-bottom: none !important;
-}
-
-:deep(.leaflet-control-zoom a:hover) {
- background: #f9fafb !important;
- color: #374151 !important;
-}
+/* Zoom-control styling is shared in assets/css/leaflet.css */
diff --git a/app/components/admin/AuthorBadge.vue b/app/components/admin/AuthorBadge.vue
index 3333fe5..331ba0f 100644
--- a/app/components/admin/AuthorBadge.vue
+++ b/app/components/admin/AuthorBadge.vue
@@ -1,4 +1,6 @@
+
+
+
+
+
+
+
+ Skills wanted
+
+
+ Share
+
+
+
+
+ {{ s.skill }}
+
+
+
+
+
+
+ Know what expertise this needs? List it so the right people can find it.
+
+
+
+
+
diff --git a/app/components/revision/EditModal.vue b/app/components/revision/EditModal.vue
index 6211f52..43d734f 100644
--- a/app/components/revision/EditModal.vue
+++ b/app/components/revision/EditModal.vue
@@ -10,6 +10,7 @@ import type {
CaseStudyLinkRow,
CaseStudyMetricRow,
} from '../caseStudy/CaseStudyFields.vue'
+import { cleanLinkRows } from '~/utils/link-rows'
// Edit / Suggest-edit modal. Wraps the shared IssueFields / CaseStudyFields
// groups seeded from the live node and submits to the same PATCH endpoint the
@@ -175,9 +176,7 @@ function close() {
// every field is forwarded (the server diffs against the live node and records
// only real changes / no-ops an empty diff).
function buildIssueBody() {
- const cleanedLinks = links.value
- .map((l) => ({ url: l.url.trim(), title: l.title.trim() || undefined }))
- .filter((l) => l.url)
+ const cleanedLinks = cleanLinkRows(links.value)
const body: Record = {
title: title.value.trim(),
summary: summary.value.trim(),
@@ -199,12 +198,8 @@ function buildIssueBody() {
// Build the case-study PATCH body. Mirrors pages/new-case-study.vue's submit
// cleaning (sources/links/metrics trimmed + filtered, lessons rows → string[]).
function buildCaseStudyBody() {
- const cleanedSources = sources.value
- .map((s) => ({ url: s.url.trim(), title: s.title.trim() || undefined }))
- .filter((s) => s.url)
- const cleanedLinks = csLinks.value
- .map((l) => ({ url: l.url.trim(), title: l.title.trim() || undefined }))
- .filter((l) => l.url)
+ const cleanedSources = cleanLinkRows(sources.value)
+ const cleanedLinks = cleanLinkRows(csLinks.value)
const cleanedMetrics = metrics.value
.map((m) => ({
label: m.label.trim(),
diff --git a/app/components/revision/RevisionTimeline.vue b/app/components/revision/RevisionTimeline.vue
index be27a50..569a9c8 100644
--- a/app/components/revision/RevisionTimeline.vue
+++ b/app/components/revision/RevisionTimeline.vue
@@ -1,6 +1,7 @@
diff --git a/app/components/user/InterestsEditor.vue b/app/components/user/InterestsEditor.vue
new file mode 100644
index 0000000..ea398db
--- /dev/null
+++ b/app/components/user/InterestsEditor.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+ {{ interest.label }}
+
+
+
+
+
+
+
+ You have reached the limit of 10 interests. Remove one to add another.
+
+
+
+ targets topics like: {{ previewNames }}
+
+
+ nothing close in the catalog yet, you will be first to know
+
+
+
+ checking the catalog...
+
+
+
+ Popular
+
+
+
+ {{ tag.name }}
+
+
+
+
+
diff --git a/app/components/user/NewsletterPrefs.vue b/app/components/user/NewsletterPrefs.vue
new file mode 100644
index 0000000..5fbd306
--- /dev/null
+++ b/app/components/user/NewsletterPrefs.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save newsletter preferences
+
+
+
diff --git a/app/composables/useUmami.ts b/app/composables/useUmami.ts
index 50ba1ce..af88412 100644
--- a/app/composables/useUmami.ts
+++ b/app/composables/useUmami.ts
@@ -1,5 +1,5 @@
export function useUmami() {
- function track(event: string, data?: Record) {
+ function track(event: string, data?: Record) {
if (typeof window === 'undefined' || typeof window.umami === 'undefined') return
if (data) {
window.umami.track(event, data)
diff --git a/app/pages/admin/users/[id].vue b/app/pages/admin/users/[id].vue
index 0d97d29..811cda3 100644
--- a/app/pages/admin/users/[id].vue
+++ b/app/pages/admin/users/[id].vue
@@ -1,4 +1,6 @@
+
+
+
+
+
+
+
+
+ {{ o.label }}
+
+
+
+
+ Semantic search is temporarily unavailable — showing the most recent case studies instead.
+
+
+ Showing {{ studies.length }} case stud{{ studies.length === 1 ? 'y' : 'ies' }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/contribute.vue b/app/pages/contribute.vue
new file mode 100644
index 0000000..901b409
--- /dev/null
+++ b/app/pages/contribute.vue
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+
+ All labels
+
+ {{ totalNeeding }}
+
+
+
+
+ {{ meta.title }}
+
+ {{ counts[label] }}
+
+
+
+
+
+ {{ LABEL_META[activeLabel]?.hint }}
+
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+
+ Issues & solutions ({{ issues.length }})
+
+
+
+
+
+ {{ LABEL_META[l]?.title ?? l }}
+
+
+
+
+
+
+
+
+ Case studies ({{ caseStudies.length }})
+
+
+
+
+
+ {{ LABEL_META[l]?.title ?? l }}
+
+
+
+
+
+
+
+
+
+
+ More ways to help
+
+
+
+
+
+
+ {{ way.label }}
+
+
+
+
+ {{ way.description }}
+
+
+
+
+
+
diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue
index 59ab580..7fa8610 100644
--- a/app/pages/dashboard.vue
+++ b/app/pages/dashboard.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+ New Issue
+
+
+
+
+
+
+ Reset
+
+
+
+
+ {{ issues.length }} issue{{ issues.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/map.vue b/app/pages/map.vue
new file mode 100644
index 0000000..412310c
--- /dev/null
+++ b/app/pages/map.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+ Map of every geolocated issue, solution, and case study
+
+
+
+
+ track('Map marker click', { kind })"
+ />
+
+
+ Loading map…
+
+
+
+
+
+
+
+
+ {{ k.label }}
+
+ {{ countOf(k.kind) }}
+
+
+
+
+
+
+ Browse all:
+
+
+ issues
+
+
+ ·
+
+
+ case studies
+
+
+
+
diff --git a/app/pages/new-case-study.vue b/app/pages/new-case-study.vue
index 9ccbd5f..7d7e9a7 100644
--- a/app/pages/new-case-study.vue
+++ b/app/pages/new-case-study.vue
@@ -1,5 +1,6 @@
+
+
+
+
+
+
+
+
+ Step {{ step }} of {{ TOTAL_STEPS }}
+
+
+ Skip for now
+
+
+
+
+
+
+
+ 0{{ i + 1 }}
+
+
+ {{ s.title }}
+
+
+ {{ stepCounts[i] }}
+
+
+
+
+
+
+
+
+
+ 0{{ step }}
+
+
+
+
+
+
+
+
+ {{ currentStep.title }}
+
+
+ {{ currentStep.description }}
+
+
+
+
+
+
+
+
+
+ {{ q.title }}
+
+
+ {{ q.area }}
+
+
+
+
+
+
+
+
+ Try
+
+
+
+ {{ example.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+
+ Continue
+
+
+ Finish
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You're all set
+
+
+ {{ doneSummary }}
+
+
+
+ See where you can help
+
+
+ Explore the catalog
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/settings.vue b/app/pages/settings.vue
index a4a02dd..d5a18ea 100644
--- a/app/pages/settings.vue
+++ b/app/pages/settings.vue
@@ -483,11 +483,45 @@ definePageMeta({
Add a credential
-
+
03
+
+ Interests
+
+
+
+ Topics you care about, in your own words. They can be anything, even topics the
+ catalog does not cover yet. We use them to surface issues and wins that matter to you.
+
+
+
+
+
+
+
+
+ 04
+
+
+ Newsletter
+
+
+
+ Choose whether we may email you and what to include. Nothing is sent without
+ your opt-in.
+
+
+
+
+
+
+
+
+ 05
+
Session
diff --git a/app/pages/solutions.vue b/app/pages/solutions.vue
new file mode 100644
index 0000000..aff4e2f
--- /dev/null
+++ b/app/pages/solutions.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+ {{ s.label }}
+
+
+
+
+ {{ solutions.length }} solution{{ solutions.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/tag/[slug].vue b/app/pages/tag/[slug].vue
index ec432b1..7189518 100644
--- a/app/pages/tag/[slug].vue
+++ b/app/pages/tag/[slug].vue
@@ -93,7 +93,7 @@ const allTags = computed(() => {
-
+
#{{ tagSlug }}
diff --git a/app/pages/tags.vue b/app/pages/tags.vue
new file mode 100644
index 0000000..d078672
--- /dev/null
+++ b/app/pages/tags.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #{{ tag.slug }}
+
+
+ {{ tag.uses }}
+
+
+
+
+ {{ visibleTags.length }} topic{{ visibleTags.length === 1 ? '' : 's' }}
+
+
+
+
diff --git a/app/utils/link-rows.ts b/app/utils/link-rows.ts
new file mode 100644
index 0000000..86fe017
--- /dev/null
+++ b/app/utils/link-rows.ts
@@ -0,0 +1,10 @@
+// Trim a repeatable url/title input group (issue solution links, case-study
+// sources) down to what the API accepts: whitespace stripped, blank titles
+// collapsed to undefined, and rows without a url dropped entirely.
+export function cleanLinkRows
(
+ rows: T[],
+): Array<{ url: string; title?: string }> {
+ return rows
+ .map((r) => ({ url: r.url.trim(), title: r.title.trim() || undefined }))
+ .filter((r) => r.url)
+}
diff --git a/app/utils/relative-time.ts b/app/utils/relative-time.ts
new file mode 100644
index 0000000..019c173
--- /dev/null
+++ b/app/utils/relative-time.ts
@@ -0,0 +1,19 @@
+// Human-friendly "time ago" label shared across admin queue cards, the
+// dashboard activity feed, revision timelines, and user profiles. Compact
+// call sites (e.g. the dashboard feed) pass `suffix: ''` to render "5m"
+// instead of "5m ago"; everything else keeps the default " ago".
+export function formatRelative(
+ date: string | null | undefined,
+ { suffix = ' ago' }: { suffix?: string } = {},
+): string {
+ if (!date) return ''
+ const d = new Date(date)
+ const diffMin = Math.floor((Date.now() - d.getTime()) / 60000)
+ if (diffMin < 1) return 'just now'
+ if (diffMin < 60) return `${diffMin}m${suffix}`
+ const diffH = Math.floor(diffMin / 60)
+ if (diffH < 24) return `${diffH}h${suffix}`
+ const diffD = Math.floor(diffH / 24)
+ if (diffD < 30) return `${diffD}d${suffix}`
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+}
diff --git a/content/guide/ai-agents.md b/content/guide/ai-agents.md
deleted file mode 100644
index 1714d7d..0000000
--- a/content/guide/ai-agents.md
+++ /dev/null
@@ -1,112 +0,0 @@
----
-title: Contributing with AI Agents
-description: Best practices for AI assistants contributing through the CommunityFix MCP server — search before you create, respect scope, and ground everything in evidence.
----
-
-# Contributing with AI Agents
-
-CommunityFix exposes an MCP server so AI assistants can search, read, and contribute on a person's behalf. Automated contributions are welcome — but the bar is the same as for humans: well-scoped, non-duplicate, evidence-backed. This guide is for AI clients (and the people who build them).
-
----
-
-## Read before you write
-
-Two tools give you the context to contribute well — use them at the start of a session, not after you've already drafted something:
-
-- **`get_whitepaper`** — the platform's mission, principles, and model.
-- **`get_guide`** — call with no `slug` to list guides, then fetch the relevant one (e.g. `writing`, `structuring`, `evidence`). The advice in those guides applies to you directly.
-
----
-
-## Search before you create
-
-The single most important rule: **never create blind.**
-
-1. **`search_issues_solutions`** — semantic search; run it with the problem or approach you're about to add.
-2. **`get_tree`** — read the descendant tree of a relevant node to see what sub-issues, solutions, and case studies already exist.
-3. **`suggest_more`** — find nodes similar to one you're looking at.
-
-If a node already covers your point, **update it** rather than adding a near-duplicate. Treat searching first as your responsibility — the server trusts you to do it and will create whatever you ask for.
-
----
-
-## Use the right tool for the kind
-
-| Intent | Tool | Required parent |
-|---|---|---|
-| New problem / facet | `create_issue` | optional `parentId` |
-| Proposed fix for an issue | `create_solution` | `parentId` = an **issue** |
-| Real-world deployment of a solution | `create_case_study` | `solutionId` = a **solution** |
-| Edit existing content | `update_issue` / `update_solution` / `update_case_study` | — |
-
-Tools are split by node kind on purpose: a solution's parent must be an issue (not another solution), and an update errors if the `id` resolves to the wrong kind. Pick the matching tool rather than forcing one. See the [Structuring guide](/guide/structuring) for the modeling decisions behind this.
-
----
-
-## Respect scope — emit more calls, not bigger nodes
-
-Each node must say exactly one thing. When your material spans several problems or approaches, that's **several tool calls**, not one stuffed node:
-
-- Facets of a problem → multiple `create_issue` calls (as sub-issues).
-- Competing approaches → multiple `create_solution` calls on the same parent.
-- The same solution tried in several places → one `create_case_study` per place.
-
-Never write an "alternatives", "sub-issues", or "prior attempts" section inside a single node's body.
-
----
-
-## Write summaries as real synopses
-
-`summary` is required, plain text, ≤280 characters. Produce a **genuine, standalone synopsis** — not the first 280 characters of `description` truncated. If you wrote a long description, distill it into a complete sentence or two that stand on their own. Put long-form detail in `description` (markdown).
-
----
-
-## Ground everything in evidence — never invent it
-
-This is where automated contribution most often goes wrong:
-
-- **Do not fabricate** statistics, sources, locations, dates, costs, or outcomes. If you don't have a real source, say so or leave the field out.
-- Put citations in the right field: solutions and case studies have `links`; case studies also have `sources` for claim-backing references; **issues have no links field** — cite inline in the description.
-- For case studies, choose the `outcome` honestly (`success` / `partial` / `failed` / `inconclusive` / `ongoing`). Reporting a real `partial` or `failed` result is far more valuable than an inflated `success`. Full standard: [Evidence guide](/guide/evidence).
-
----
-
-## Work with moderation, not around it
-
-- New and edited content is reviewed by an AI moderation pipeline before it goes live. Topic **tags** and **SDG alignment** are assigned automatically — don't try to set them.
-- Quality is enforced: a pattern of rejected submissions can lead to a temporary suspension of the account you're acting for. Slow, correct, well-sourced contributions beat volume.
-- `whoami` tells you which user you're authoring as; `get_user` reads any public profile.
-
----
-
-## Operational etiquette
-
-- **Mind the limits.** Writes and embedding-backed searches are rate-limited per user. Don't loop aggressively; back off on a `429` / rate-limit message and retry after the indicated delay.
-- **Reads are cheap and idempotent; writes are not.** Don't repeat a create to "make sure it worked" — check first with a search or `get_issue`.
-- **Dedup is on you.** Nothing stops you from creating a near-duplicate, so search first and update an existing node when one already fits.
-
----
-
-## A model workflow
-
-1. `get_whitepaper` / `get_guide` for context (once per session).
-2. `search_issues_solutions` for the thing you intend to add.
-3. `get_tree` on the closest match to understand what already exists.
-4. Decide: **update** an existing node, or **create** a new, well-scoped one.
-5. Draft with a real summary, an evidenced description, and citations in the correct fields.
-6. Call the matching `create_*` / `update_*` tool.
-7. Need several nodes? Repeat per node — one thing each.
-
----
-
-## Don'ts
-
-| Don't | Do instead |
-|---|---|
-| Create without searching | `search_issues_solutions` + `get_tree` first |
-| Add a near-duplicate of an existing node | Inspect matches; update the one that fits |
-| Pack alternatives/sub-issues into one node | Emit separate `create_*` calls |
-| Truncate the description into the summary | Write a standalone synopsis |
-| Invent sources, metrics, or outcomes | Use real, checkable evidence — or omit |
-| Nest a solution under a solution | Attach a **case study** to the solution |
-| Hammer the API in a tight loop | Respect rate limits; reads before writes |
diff --git a/content/guide/authoring.md b/content/guide/authoring.md
new file mode 100644
index 0000000..9213631
--- /dev/null
+++ b/content/guide/authoring.md
@@ -0,0 +1,213 @@
+---
+title: Authoring Guide
+description: Instructions for contributing to CommunityFix, for humans and AI agents. How to model the tree, write each node type, source evidence, and work through the MCP server.
+---
+
+# Authoring Guide
+
+These are the working instructions for contributing to CommunityFix. They apply to humans and to AI agents acting through the MCP server; the quality bar is the same for both. Read the whitepaper for the mission and model. Read this guide before writing anything.
+
+---
+
+## The model
+
+Everything on CommunityFix is one of three node types, arranged in a tree:
+
+- **Issue**: a problem worth solving. An issue can contain sub-issues (narrower facets of the problem) and solutions.
+- **Solution**: a proposed way to address one specific issue. Solutions are leaves of the tree. Their parent must be an issue, never another solution.
+- **Case study**: a structured record of one real-world attempt at a solution: where, by whom, what happened, what it cost, what was learned. Case studies attach to a solution and are not part of the issue tree.
+
+```
+Issue: Diesel buses pollute the downtown core
+├─ Sub-issue: Peak-hour emissions near schools
+├─ Solution: Electrify the municipal bus fleet
+│ ├─ Case study: Shenzhen full-fleet electrification (success)
+│ └─ Case study: Bogotá pilot route (partial)
+└─ Solution: Low-emission zone with diesel restrictions
+ └─ Case study: London ULEZ expansion (ongoing)
+```
+
+The fastest disambiguator: **issues describe what is wrong, solutions prescribe what to do, case studies record what actually happened.**
+
+---
+
+## Core rules
+
+1. **Search before you create.** Never create blind. Run a search for the problem or approach you are about to add, inspect the closest matches, and update an existing node when one already covers your point. Nothing blocks duplicates for you; avoiding them is your responsibility. A near-duplicate splits votes, attention, and case studies across two nodes and weakens both.
+2. **One node, one thing.** Each node states a single problem or a single approach. When your material spans several problems or approaches, create several nodes instead of one stuffed node.
+3. **Never invent evidence.** Do not fabricate statistics, sources, locations, dates, costs, or outcomes. If you have no real source, say so or leave the field out.
+4. **Write the summary as a real synopsis.** `summary` is required, plain text, and at most 280 characters. It must be a complete, standalone synopsis of the node, never the first 280 characters of the description cut off mid-thought.
+5. **Do not set tags or SDGs.** Topic tags and SDG alignment are assigned automatically during moderation.
+
+---
+
+## Choose the node type
+
+| You want to capture | Create | Parent |
+|---|---|---|
+| A problem | Issue (top-level) | none |
+| A narrower facet of an existing problem | Sub-issue | the broader issue |
+| A proposed way to fix an issue | Solution | the issue it addresses |
+| Another approach to the same issue | Sibling solution | the same issue |
+| A real-world attempt at a solution | Case study | the solution |
+
+Apply these tests:
+
+- If a "problem" already contains a fix ("we need a bike lane"), it is a solution wearing an issue's clothes. Reframe it as the underlying problem ("cyclists face unsafe conditions on Route X") and add the bike lane as a solution.
+- If you are recording that a specific place tried something, that is a case study on the existing solution, not a new solution. A solution stays general and portable ("electrify the municipal bus fleet"); a case study is one concrete deployment with a real location, outcome, metrics, and cost.
+- Make something a sub-issue when it only makes sense as a facet of a parent problem. Make it a top-level issue when it stands on its own and could attract solutions independently.
+
+When decomposing a large issue into sub-issues, keep them mutually exclusive, independently understandable, and right-sized: narrow enough that a single solution could plausibly address each one. "Climate change" is too broad; "diesel bus emissions in the downtown core" is actionable.
+
+---
+
+## Respect scope
+
+Never pack any of the following into one node's body:
+
+- Sub-problems or facets: create child issues instead.
+- Alternative or competing approaches, pros/cons comparisons: create sibling solutions on the same parent issue.
+- Concrete deployments ("City X tried this"): create case studies on the solution.
+- Surveys of prior attempts or "state of the art" sections: these belong nowhere in a single node; they emerge from siblings in the tree.
+
+If you catch yourself writing headings like "Alternatives", "Sub-issues", "Other approaches", or "Why X failed" inside one node, stop and create the sibling or child nodes instead. A good node reads like a focused statement of one thing.
+
+---
+
+## Write an issue
+
+An issue describes a problem worth solving, without prescribing the fix.
+
+**Summary** (required, ≤280 chars), follow this pattern:
+
+> [Affected group/area] experiences [specific problem] [context/frequency], causing [primary impact].
+
+Good: "Pedestrians on Rue de Rivoli face dangerous crossings at 3 unsignalized intersections, causing 12 injuries per year."
+Bad: "We need a traffic light on Rue de Rivoli." (That is a solution, not a problem.)
+Bad: "Pedestrians on Rue de Rivoli face dangerous crossings because the intersections were built in the 1960s when traffic volumes were lower and the road was designed primarily for through-tr" (This is the description sliced to the character cap, cut off mid-word. A summary must be a self-contained sentence that ends cleanly, not the opening of the description.)
+
+**Description** (optional, markdown), cover in order:
+
+1. Observable evidence: what can be seen, measured, or documented. Separate facts from interpretation.
+2. Who is affected: the specific population, approximate numbers, nature of the harm.
+3. Scope boundaries: where and when the problem occurs, and what is explicitly excluded.
+4. Current state vs. desired state: "Response time averages 48 hours; residents expect under 4 hours."
+5. Impact if unaddressed.
+
+Avoid these mistakes: vague framing ("roads are bad"), overly broad topics ("climate change"), emotional venting ("the mayor doesn't care"), and missing scope ("pollution"). Always name which place, what is wrong, who is affected, and at what magnitude.
+
+Issues have no `links` field. Cite evidence inline in the description (name the source and link it in markdown). Keep the summary source-free.
+
+---
+
+## Write a solution
+
+A solution is a specific proposed intervention for its parent issue: actionable, evidence-informed, and honest about trade-offs.
+
+**Summary** (required, ≤280 chars), follow this pattern:
+
+> [Action verb] [specific intervention] [by/for whom] to [expected outcome].
+
+Good: "Install protected bike infrastructure on the 2km stretch of Avenue X to reduce cyclist injuries by an estimated 60%."
+Bad: "Make cycling safer." (No intervention specified.)
+
+**Description** (optional, markdown), cover in order:
+
+1. The proposal: the specific action, who would implement it, at what scale, in what timeframe.
+2. Why it would work: the causal mechanism, with assumptions stated explicitly.
+3. Evidence: research, data, or precedents that support the approach.
+4. Implementation path: practical steps, responsible parties, estimated timeline and resources.
+5. Trade-offs and limitations: what this will not solve, what could go wrong, what conditions must hold.
+
+Use the `links` field for supporting resources: research papers, technical specs, cost models, reference implementations, precedent projects. Keep the solution's scope proportionate to its parent issue.
+
+---
+
+## Write a case study
+
+A case study documents one real-world implementation of a solution in one place. Failed implementations are especially valuable. Required fields: `solutionId`, `outcome`, `locationName`, `latitude`, `longitude`.
+
+**Outcome**, choose honestly:
+
+| Outcome | When to use |
+|---|---|
+| `success` | Clear evidence that stated goals were met |
+| `partial` | Some goals met, others not; specify which |
+| `failed` | Goals were not met; document why |
+| `inconclusive` | Not enough data or time to determine results |
+| `ongoing` | Still in progress; include interim indicators |
+
+`partial` is often the most accurate choice. Never inflate results; a well-documented `failed` entry is some of the most valuable content on the platform.
+
+**Other fields:**
+
+- **Location**: be as specific as meaningful. Neighborhood-level beats city-level for local interventions.
+- **Description**: a narrative of what actually happened, not what was planned. Timeline, deviations from the plan and why, context that influenced outcomes, challenges and how they were handled.
+- **Metrics**: one row per measurement, each with the indicator name, baseline (value before), result (value after), unit, and data source. Distinguish outputs (what was produced: "3 km of bike lane built") from outcomes (what changed: "cyclist injuries dropped 40%"). Outcomes are what readers care about.
+- **Cost**: total cost, per-unit cost where meaningful, and the funding source. Others need to know where the money came from to judge reproducibility.
+- **Sources**: citations that back the claims (the report behind a metric, the audit behind a cost).
+- **Links**: supplementary artifacts (a repo, a hosted PDF, a demo video, a photo album). Do not mix sources and links.
+- **Lessons learned**: one standalone takeaway per entry. What would you do differently, what surprised you, what conditions were necessary, what transfers to other contexts.
+
+When documenting failure, say which kind it was: failure of concept (the idea was wrong), failure of execution (sound idea, poor implementation), or failure of context (might work elsewhere, local conditions prevented it).
+
+---
+
+## Evidence standard
+
+Every factual or quantitative claim must be checkable by a reader.
+
+- State where each number or fact comes from. An unsourced statistic is an assertion, not evidence.
+- Prefer primary and independent sources: peer-reviewed research, government statistics, audited financials, independent evaluations, reputable investigative media.
+- Use news reporting, industry reports, NGO publications, and expert blogs for context only, and cross-check key numbers.
+- Never use promotional material from the implementer, vendor case studies, or anonymous social posts as the sole backing for a claim. When the only source is the party that benefits, say so and treat the claim as unverified.
+- Keep facts visibly separate from interpretation. "Average ambulance response time was 48 minutes in 2023 (city EMS annual report)" is observed; "this suggests dispatch routing is the bottleneck" is interpretation. Readers can accept the first and debate the second.
+- Be candid about uncertainty: say what you do not know and where data is thin or contested.
+
+---
+
+## Location and scale
+
+Issues and solutions accept an optional location and a `scale`: `neighborhood`, `city`, `region`, `national`, or `global`. Case studies require a precise location. Set the scale that matches the node's reach: a pothole is `neighborhood`, a carbon tax is `national`. Accurate location and scale make the catalog browsable by place and let similar contexts be compared.
+
+---
+
+## Working through the MCP server
+
+The tool surface is documented on the [MCP page](/mcp). Follow this workflow:
+
+1. Call `get_whitepaper` and `get_guide` once per session for context.
+2. Call `search_issues_solutions` with the thing you intend to add.
+3. Call `get_tree` on the closest match to see what sub-issues, solutions, and case studies already exist (`suggest_more` finds related nodes; `list_case_studies` lists a solution's deployments).
+4. Decide: update the existing node, or create a new, well-scoped one.
+5. Draft with a real summary, an evidenced description, and citations in the correct fields.
+6. Call the matching `create_*` or `update_*` tool. Several nodes means several calls, one per node.
+
+**Editing is collaborative (wiki-style).** Anyone can change any node. If you authored the node (or are an admin) your edit applies immediately; otherwise it is recorded as a pending revision proposal for the owner or an admin to review. The response carries `applied: true` (live edit) or `applied: false` with the pending `revision`. Use `propose_edit` when you want one tool for any node kind, `list_revisions` to see a node's history and visible proposals, and `review_revision` (owner/admin only) to approve or reject a proposal. Include a `note` explaining your change; the reviewer sees it.
+
+**Moderation.** New and edited content goes through AI moderation before going live. Tags and SDG alignment are assigned there automatically. A pattern of rejected submissions can lead to a temporary suspension of the account you act for, so favor slow, correct, well-sourced contributions over volume. `whoami` tells you which user you are authoring as.
+
+**Writing rules for AI agents.** These apply to content generated by an LLM (human contributors may write however they like):
+
+- **Declare your model.** Pass your exact model id in the optional `model` field on every `create_*`, `update_*`, and `propose_edit` call.
+- **Do not use the em dash character (`—`).** Write sentences that never need it. Use a period, a comma, a colon, or parentheses instead.
+- Write plain, neutral prose. No filler, no marketing tone, no headings inside a summary.
+
+**Etiquette.**
+
+- Writes and embedding-backed searches are rate limited per user. Do not loop aggressively; back off on a 429 and retry after the indicated delay.
+- Reads are cheap and idempotent, writes are not. Never repeat a create to "make sure it worked"; verify with a search or `get_issue` first.
+
+---
+
+## Final checklist
+
+- [ ] I searched first and this is not a duplicate.
+- [ ] Right node type, parented correctly (solution → issue, case study → solution).
+- [ ] The node says exactly one thing; siblings and children carry the rest.
+- [ ] The summary is a standalone synopsis, not a truncated description.
+- [ ] Every factual claim names a checkable source; nothing is invented.
+- [ ] Citations sit in the right field (issues: inline; solutions: `links`; case studies: `sources` for claims, `links` for artifacts).
+- [ ] Case study outcome is honest, including `partial` or `failed`.
+- [ ] Location and scale are set where they apply.
+- [ ] No em dash characters anywhere in the text.
diff --git a/content/guide/evidence.md b/content/guide/evidence.md
deleted file mode 100644
index 4156918..0000000
--- a/content/guide/evidence.md
+++ /dev/null
@@ -1,97 +0,0 @@
----
-title: Evidence & Sourcing
-description: How to back your contributions with credible, verifiable sources — the evidence standard CommunityFix holds all content to.
----
-
-# Evidence & Sourcing
-
-CommunityFix is only as trustworthy as the claims inside it. Decisions about what to prioritize, fund, and incubate rest on the catalog being accurate — so every contribution should be grounded in evidence a reader can check. This guide applies to issues, solutions, **and** case studies.
-
----
-
-## The verifiability standard
-
-A claim is only as strong as a reader's ability to confirm it independently.
-
-- State **where a number or fact comes from**. An unsourced statistic is treated as an assertion, not evidence.
-- Prefer **primary and independent** sources over second-hand summaries.
-- Separate what is **observed/measured** from what is **inferred/argued** — don't present interpretation as fact.
-
----
-
-## Source quality hierarchy
-
-| Tier | Examples | Use for |
-|---|---|---|
-| **Prefer** | Peer-reviewed research, government statistics, audited financials, independent evaluations, reputable investigative media | Any factual or quantitative claim |
-| **Use with caution** | News reporting, industry reports, NGO publications, expert blogs | Context and corroboration; cross-check key numbers |
-| **Avoid as proof** | Promotional material from the implementer, vendor case studies, anonymous social posts, sources with a conflict of interest | At most, illustration — never as the sole backing for a claim |
-
-When the only available source is the party that benefits from the claim, say so explicitly and treat the claim as unverified.
-
----
-
-## Fact vs. interpretation
-
-Keep the two visibly separate:
-
-> **Observed:** "Average ambulance response time was 48 minutes in 2023 (city EMS annual report)."
-> **Interpretation:** "This suggests dispatch routing is the bottleneck."
-
-Readers can accept the first and debate the second. Blending them buries the evidence.
-
----
-
-## Where citations go (by node type)
-
-- **Issues** — there is no links field on an issue. Cite evidence *inline in the description* (name the source, link it in markdown) and describe what it shows. Keep the summary clean and source-free.
-- **Solutions** — use the **`links`** field for supporting resources: research, technical specs, cost models, reference implementations, precedent projects.
-- **Case studies** — keep **`sources`** for citations that *back the claims* (the report behind a metric, the audit behind a cost). Use **`links`** for supplementary *artifacts* (a repo, a hosted PDF, a demo video, a photo album). Don't mix the two.
-
----
-
-## Quantitative claims
-
-A number is only useful with its context. For every metric, give:
-
-- **Indicator** — what was measured.
-- **Baseline** — the value *before* (or a control to compare against).
-- **Result** — the value *after*.
-- **Unit** — so the magnitude is unambiguous.
-- **Source** — where the figure comes from.
-
-Distinguish **outputs** (what was produced — "3 km of bike lane built") from **outcomes** (what changed — "cyclist injuries fell 40%"). Outcomes are what readers care about; outputs alone can mislead.
-
----
-
-## Be honest about uncertainty
-
-Credibility comes from candor, not confidence.
-
-- Say what you **don't** know, and where the data is thin or contested.
-- For case studies, pick the outcome honestly — **`partial`** is often the most accurate, and a well-documented **`failed`** result is some of the most valuable content on the platform. Don't inflate results.
-- Note the conditions a result depended on, so others can judge whether it transfers to their context.
-
----
-
-## What will not pass review
-
-Moderation and the community filter out content that undermines trust:
-
-- Fabricated, misattributed, or unverifiable statistics.
-- Promotional spin presented as neutral evidence.
-- Hate speech, harassment, or private personal information.
-- Implausible claims with no checkable backing.
-
-If you wouldn't stake your own credibility on a claim, don't publish it without a source — or frame it explicitly as an open question.
-
----
-
-## Quick checklist
-
-- [ ] Every factual/quantitative claim names a source.
-- [ ] Sources lean toward the **Prefer** tier; conflicts of interest are disclosed.
-- [ ] Facts are separated from interpretation.
-- [ ] Metrics include baseline, result, unit, and source.
-- [ ] Outcome (for case studies) is honest, including partial or failed.
-- [ ] Citations are in the right field for the node type.
diff --git a/content/guide/getting-started.md b/content/guide/getting-started.md
deleted file mode 100644
index 3895599..0000000
--- a/content/guide/getting-started.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-title: Getting Started
-description: New to CommunityFix? How the issue → solution → case study model works, what happens after you submit, and how to make a strong first contribution.
----
-
-# Getting Started
-
-CommunityFix is a shared, public catalog of the world's problems — and the ideas and real-world attempts to solve them. This guide explains how the platform is organized and how to make a first contribution that lasts. For *why* the platform exists (the mission, principles, and incubation model), read the whitepaper.
-
----
-
-## The model: a tree of problems and solutions
-
-Everything on CommunityFix is one of three node types:
-
-- **Issue** — a problem worth solving. An issue can have **sub-issues**: narrower facets of a bigger problem.
-- **Solution** — a proposed way to address one specific issue. Solutions are *leaves* — they hang off an issue and cannot have sub-solutions.
-- **Case study** — a record of one real-world attempt to implement a solution: where, by whom, what happened, what it cost, and what was learned. Case studies attach to a solution.
-
-```
-Issue: Diesel buses pollute the downtown core
-├─ Sub-issue: Peak-hour emissions near schools
-├─ Solution: Electrify the municipal bus fleet
-│ ├─ Case study: Shenzhen full-fleet electrification (success)
-│ └─ Case study: Bogotá pilot route (partial)
-└─ Solution: Low-emission zone with diesel restrictions
- └─ Case study: London ULEZ expansion (ongoing)
-```
-
-Choosing the right node type is the most common beginner question — the [Structuring guide](/guide/structuring) walks through it.
-
----
-
-## Two text fields
-
-Issues and solutions each carry:
-
-- **Summary** — required, plain text, **≤280 characters**. A complete, standalone synopsis of the node — *not* the first 280 characters of the description with the rest cut off. A reader who sees only the summary should grasp the whole point.
-- **Description** — optional, markdown, no length limit. The long-form body: evidence, context, mechanism, trade-offs.
-
-Case studies are **structured** instead of free-form: outcome, location, dates, metrics, cost, sources, and lessons learned. Exactly how to write each field well lives in the [Writing guide](/guide/writing).
-
----
-
-## The lifecycle of a contribution
-
-1. **Search first.** Look for an existing node before creating one. If something close already exists, improve it instead of adding a near-duplicate — the catalog stays clean and your effort compounds.
-2. **Create** the right node type, tightly scoped to one thing.
-3. **Review.** New and edited content goes through AI moderation. Topic **tags** and **SDG alignment** are assigned automatically during this step — you never tag manually. Approved content goes live; weak submissions come back for revision.
-4. **Live.** Your node is public, searchable, and open to solutions, sub-issues, case studies, votes, and discussion from everyone else.
-5. **Verification.** Admins can mark a case study as independently **verified** once its claims hold up — a strong trust signal for readers.
-
-> A repeated pattern of rejected submissions can lead to a temporary posting suspension (appealable). Reviewing the guides first is the easiest way to never hit it.
-
----
-
-## Make a strong first contribution
-
-- **Start from a real problem you understand.** Frame *what is wrong*, for whom, and with what impact — without smuggling in a fix (that's a solution).
-- **Be specific.** Name the place, the population, the magnitude, the timeframe. "PM2.5 exceeds WHO limits 180 days/year in District 5" beats "pollution is bad".
-- **Back it with evidence.** Even one credible source raises the bar. See the [Evidence guide](/guide/evidence).
-- **Stay in scope.** One node = one thing. If you're tempted to add alternatives or sub-problems, create separate nodes instead.
-- **Write the summary as a real synopsis**, then expand in the description.
-
----
-
-## What your contribution feeds into
-
-Contributions aren't filed and forgotten. The community votes to surface what matters, promising solutions can enter an incubation pathway toward sustainable ventures, and impact is tracked openly. Well-scoped, well-evidenced nodes are the raw material that makes all of that possible — quality compounds for everyone.
-
----
-
-## Where to go next
-
-- [Writing guide](/guide/writing) — field-by-field patterns for issues, solutions, and case studies.
-- [Structuring guide](/guide/structuring) — modeling problems into a clean tree; scope and granularity.
-- [Evidence guide](/guide/evidence) — sourcing standards every node is held to.
-- [AI agents guide](/guide/ai-agents) — best practices when contributing through the MCP server.
diff --git a/content/guide/structuring.md b/content/guide/structuring.md
deleted file mode 100644
index 360a5b5..0000000
--- a/content/guide/structuring.md
+++ /dev/null
@@ -1,118 +0,0 @@
----
-title: Structuring Problems
-description: How to model a problem as issues, sub-issues, solutions, and case studies — getting scope and granularity right, and avoiding duplicates.
----
-
-# Structuring Problems
-
-The value of CommunityFix comes from a *clean* tree: each node says one thing, sits in the right place, and isn't a duplicate of something that already exists. This guide is about modeling — deciding what to create and where it belongs — not about wording (that's the [Writing guide](/guide/writing)).
-
----
-
-## The shape of the tree
-
-- An **issue** can contain **sub-issues** (narrower facets) and **solutions** (proposed fixes).
-- A **solution** is a leaf of the issue tree — it can't have sub-solutions — but it can carry **case studies**.
-- A **case study** documents one real-world attempt at its parent solution.
-
-Solutions always point at an **issue** as their parent (never another solution). Case studies always point at a **solution**.
-
----
-
-## Which node am I creating?
-
-| You want to capture… | Create a… | Parent |
-|---|---|---|
-| A problem | **Issue** (top-level) | none |
-| A narrower facet of an existing problem | **Sub-issue** | the broader issue |
-| A proposed way to fix an issue | **Solution** | the issue it addresses |
-| Another approach to the *same* issue | **Sibling solution** | the same issue |
-| A real-world attempt at a solution | **Case study** | the solution |
-
-The fastest disambiguator:
-
-> **Issues describe what is wrong. Solutions prescribe what to do. Case studies record what actually happened.**
-
-If a "problem" already contains a fix ("we need a bike lane"), it's a solution wearing an issue's clothes — reframe it as the underlying problem ("cyclists face unsafe conditions on Route X") and put the bike lane forward as a solution.
-
----
-
-## One node, one thing
-
-A good node reads like a focused statement of a single problem or a single approach. Resist the urge to pack everything into one place:
-
-- Multiple aspects of a problem → separate **sub-issues**.
-- Several ways to solve it → separate **sibling solutions**.
-- "We tried this in three cities" → three separate **case studies**.
-- A survey of prior attempts, an "alternatives" list, or a pros/cons comparison → these don't belong inside any single node; they emerge naturally from siblings in the tree.
-
-If you catch yourself writing headings like *Alternatives*, *Other approaches*, or *Why X failed* inside one node, stop and create the sibling nodes instead.
-
----
-
-## Decomposing a large issue
-
-Break a broad issue into sub-issues that are:
-
-- **Mutually exclusive** — sub-issues shouldn't overlap each other.
-- **Collectively meaningful** — together they cover the important facets (they don't have to be exhaustive).
-- **Independently understandable** — each sub-issue makes sense without reading its siblings.
-- **Right-sized** — narrow enough that a *single solution could plausibly address it*. That last test is the best granularity check.
-
-**Too broad:** "Climate change" — nothing actionable hangs off it directly.
-**Better:** "Diesel bus emissions in the downtown core" — a solution can target this.
-
-### Sub-issue vs. new top-level issue
-
-Make it a **sub-issue** when it only makes sense as a facet of a parent problem. Make it a **top-level issue** when it stands on its own and could attract solutions independently of any one parent.
-
----
-
-## Solution vs. case study
-
-This trips people up often:
-
-- A **solution** is the *general* idea — "Electrify the municipal bus fleet." It stays abstract and portable across places.
-- A **case study** is one *concrete deployment* of that idea — "Shenzhen electrified all 16,000 buses by 2017; here's what happened." It carries a real location, outcome, metrics, and cost.
-
-If you're recording that a specific place tried something, that's a case study against the existing solution — not a new solution.
-
----
-
-## Avoid duplicates
-
-Before creating anything, **search**. A near-duplicate splits attention, votes, and case studies across two nodes and weakens both.
-
-- Search by meaning, not just keywords, and skim the closest matches.
-- If a node already covers your point, **improve it** (edit the summary/description, add a solution, attach a case study) instead of starting a new one.
-- The API and AI assistants don't block duplicates for you — searching first and updating the closest match is the contributor's responsibility.
-
----
-
-## Place it on the map
-
-Issues and solutions can carry an optional location and a **scale** — `neighborhood`, `city`, `region`, `national`, or `global`. Case studies *require* a precise location. Set the scale that matches the node's reach: a pothole is `neighborhood`; a carbon tax is `national`. Accurate location and scale make the catalog browsable by place and let similar contexts be compared.
-
----
-
-## Common structural mistakes
-
-| Mistake | Fix |
-|---|---|
-| Solution filed as an issue | Reframe as the underlying problem; add the fix as a solution |
-| Mega-issue covering a whole domain | Split into right-sized sub-issues |
-| Two sub-issues that overlap | Merge, or redraw the boundary so they're distinct |
-| New solution that's really a deployment | Attach a case study to the existing solution |
-| Duplicate of an existing node | Update the original instead |
-| "Alternatives" section inside one node | Create sibling solutions |
-
----
-
-## Quick checklist
-
-- [ ] I picked the right node type (issue / sub-issue / solution / case study).
-- [ ] It says exactly one thing.
-- [ ] It's parented correctly (solution → issue, case study → solution).
-- [ ] A single solution could address it (if it's an issue/sub-issue).
-- [ ] I searched and it isn't a duplicate.
-- [ ] Location and scale are set where they apply.
diff --git a/content/guide/writing.md b/content/guide/writing.md
deleted file mode 100644
index 383c303..0000000
--- a/content/guide/writing.md
+++ /dev/null
@@ -1,212 +0,0 @@
----
-title: Writing Guide
-description: How to write high-quality issues, solutions, and case studies on CommunityFix.
----
-
-# Writing Guide
-
-How to write high-quality issues, solutions, and case studies on CommunityFix.
-
----
-
-## Issues
-
-An issue describes a **problem worth solving** — not a solution in disguise. Good issues enable diverse solution proposals by framing what's wrong without prescribing what to do about it.
-
-### Summary (required, max 280 characters)
-
-Follow this pattern:
-
-> [Affected group/area] experiences [specific problem] [context/frequency], causing [primary impact].
-
-**Good:** "Pedestrians on Rue de Rivoli face dangerous crossings at 3 unsignalized intersections, causing 12 injuries per year."
-
-**Bad:** "We need a traffic light on Rue de Rivoli." *(This is a solution, not a problem.)*
-
-### Description (optional, markdown)
-
-Structure your description with these elements:
-
-1. **Observable evidence** — What can be seen, measured, or documented. Separate facts from interpretation.
-2. **Who is affected** — Specific population, approximate numbers, nature of the harm.
-3. **Scope boundaries** — Where and when the problem occurs. Explicitly state what is and isn't included.
-4. **Current state vs. desired state** — The gap: "Response time averages 48 hours; residents expect under 4 hours."
-5. **Impact** — Consequences if the problem remains unaddressed.
-
-### Common mistakes
-
-| Mistake | Example | Fix |
-|---------|---------|-----|
-| Solution disguised as issue | "We need a bike lane" | "Cyclists face unsafe conditions due to [hazards]" |
-| Too vague | "Roads are bad" | Specify which roads, what's wrong, who's affected |
-| Too broad | "Climate change" | Narrow to a specific, actionable facet |
-| Emotional venting | "The mayor doesn't care" | Describe the observable problem objectively |
-| No scope | "Pollution" | "PM2.5 levels in [district] exceed WHO limits 180 days/year" |
-
-### Sub-issues
-
-When decomposing a large issue:
-
-- Each sub-issue should be understandable without reading the parent
-- Sub-issues shouldn't overlap with each other
-- Each should be narrow enough that a single solution could address it
-
----
-
-## Solutions
-
-A solution is a **specific proposed intervention** to address a parent issue. It should be actionable, evidence-informed, and honest about trade-offs.
-
-### Summary (required, max 280 characters)
-
-Follow this pattern:
-
-> [Action verb] [specific intervention] [by/for whom] to [expected outcome].
-
-**Good:** "Install protected bike infrastructure on the 2km stretch of Avenue X to reduce cyclist injuries by an estimated 60%."
-
-**Bad:** "Make cycling safer." *(Too vague — no intervention specified.)*
-
-### Description (optional, markdown)
-
-Structure your description:
-
-1. **The proposal** — Lead with the specific action. Name who would implement it, at what scale, and in what timeframe.
-2. **Why it would work** — The causal logic. What mechanism connects this action to the desired outcome? State assumptions explicitly.
-3. **Evidence** — What existing research, data, or precedents support this approach? Link to sources.
-4. **Implementation path** — Practical steps, responsible parties, estimated timeline and resources.
-5. **Trade-offs and limitations** — What won't this solve? What could go wrong? What conditions must be met?
-
-### Links
-
-Use the links field to point to supporting resources: academic papers, technical specs, cost estimates, reference implementations, or precedent projects in other cities.
-
-### Key principles
-
-- **Be specific** — Name the intervention, the actor, the scale, and the timeline
-- **Name the responsible party** — Who has the authority and resources to implement this?
-- **Cite evidence** — Even one relevant precedent is better than none
-- **Acknowledge limitations** — Credibility comes from honesty about what you don't know
-- **Stay proportionate** — The solution's scope should match the parent issue's scope
-
----
-
-## Case Studies
-
-A case study documents a **real-world implementation** of a solution — what was tried, what happened, and what others can learn from it. Failed implementations are especially valuable.
-
-### Outcome
-
-Choose honestly:
-
-| Outcome | When to use |
-|---------|-------------|
-| **Success** | Clear evidence that stated goals were met |
-| **Partial** | Some goals met, others not — specify which |
-| **Failed** | Goals were not met — document why |
-| **Inconclusive** | Not enough data or time to determine results |
-| **Ongoing** | Still in progress — include interim indicators |
-
-"Partial" is often the most accurate outcome. Don't inflate results.
-
-### Location
-
-Be as specific as meaningful. Neighborhood-level is usually better than city-level for local interventions. Precise locations enable comparison between similar contexts.
-
-### Description
-
-Write a narrative of **what actually happened** (not what was planned):
-
-- Timeline of key events and decisions
-- How the implementation deviated from the original plan and why
-- Context that influenced outcomes (political, economic, social factors)
-- Challenges encountered and how they were handled
-
-### Metrics
-
-Each metric should include:
-
-- **Indicator name** — What you measured
-- **Baseline** — The value before the intervention
-- **Actual result** — The value after
-- **Data source** — Where this number comes from
-- **Comparison** — "Compared to the previous year..." or "vs. a control area..."
-
-Distinguish **outputs** (what was produced: "3 km of bike lane built") from **outcomes** (what changed: "cyclist injuries dropped 40%").
-
-### Cost
-
-Report:
-
-- Total cost AND per-unit cost where meaningful (cost per beneficiary, per km, etc.)
-- Direct costs and known indirect costs
-- Funding source — others need to know where money came from to assess reproducibility
-
-### Sources
-
-Apply the verifiability standard: claims must be backed by independent, reliable sources.
-
-**Prefer:** Government reports, audited financials, peer-reviewed evaluations, reputable media.
-
-**Avoid:** Promotional materials from the implementer, unverified social media, sources with conflicts of interest.
-
-### Lessons learned
-
-Answer these questions:
-
-- What would you do differently next time?
-- What surprised you?
-- What conditions were necessary for this to work (or fail)?
-- What's transferable to other contexts vs. context-specific?
-
-### Documenting failure
-
-When an implementation didn't work, distinguish between:
-
-- **Failure of concept** — The idea was wrong
-- **Failure of execution** — The idea was sound but implementation was poor
-- **Failure of context** — The idea might work elsewhere, but local conditions prevented it
-
-Include enough detail for others to avoid the same mistakes.
-
----
-
-## Tags
-
-- Use **noun phrases**, not sentences ("public transit", not "we should improve public transit")
-- Be **specific** — use both broad ("transport") and narrow ("bus frequency") tags where appropriate
-- Maximum **5-7 tags** per issue
-- Avoid subjective or opinion tags ("broken system", "government failure")
-
----
-
-## Quality standards
-
-### Minimum requirements
-
-All content must be:
-
-- Written in intelligible language
-- On-topic (describes a real public problem, intervention, or implementation)
-- Not a duplicate of existing content
-- Free of hate speech, harassment, or private personal information
-- Factually plausible
-
-### What makes content stand out
-
-- **Verifiable** — Claims correspond to checkable sources
-- **Specific** — Names locations, populations, timeframes, magnitudes
-- **Neutral tone** — Represents the situation fairly, without editorial bias
-- **Properly scoped** — Not too broad, not too narrow
-- **Evidence-based** — Cites data, research, or documented precedent
-- **Acknowledges alternatives** — Shows awareness of other perspectives
-
----
-
-## Quick reference
-
-| Field | Pattern | Example |
-|-------|---------|---------|
-| Issue summary | [Who] experiences [what] [where/when], causing [impact] | "Residents of District 5 lack grocery access within 1km, leading to food insecurity for 2,000 households" |
-| Solution summary | [Verb] [intervention] [by whom] to [outcome] | "Establish a weekly mobile market serving 3 underserved neighborhoods to provide affordable fresh produce" |
-| Case study outcome | Honest assessment based on evidence | "Partial — food access improved but vendor sustainability remains uncertain after 18 months" |
diff --git a/content/mcp.md b/content/mcp.md
index 27bf567..4610bc0 100644
--- a/content/mcp.md
+++ b/content/mcp.md
@@ -5,7 +5,7 @@ description: Connect Claude Desktop, IDEs, and other AI clients to CommunityFix
# MCP Server
-CommunityFix ships a [Model Context Protocol](https://modelcontextprotocol.io/) server so any compatible AI client — Claude Desktop, Claude Code, Cursor, your own agent — can search the tree, read issues and solutions, and create new ones on your behalf.
+CommunityFix ships a [Model Context Protocol](https://modelcontextprotocol.io/) server so any compatible AI client (Claude Desktop, Claude Code, Cursor, your own agent) can search the tree, read issues and solutions, and contribute on your behalf.
The endpoint is:
@@ -41,41 +41,58 @@ The same browser-based OAuth flow runs on the first call.
## Connect from other clients
-Any MCP client that supports remote servers with OAuth 2.1 + PKCE works. Point it at `https://communityfix.org/api/mcp` — the client discovers everything else from the standard well-known endpoints:
+Any MCP client that supports remote servers with OAuth 2.1 + PKCE works. Point it at `https://communityfix.org/api/mcp` and the client discovers everything else from the standard well-known endpoints:
- `/.well-known/oauth-protected-resource`
- `/.well-known/oauth-authorization-server`
+- `/.well-known/mcp/server-card.json`
-Clients self-register via `POST /oauth/register` ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) — there is no client ID to copy around manually.
+Clients self-register via `POST /oauth/register` ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)); there is no client ID to copy around manually.
## Available tools
+### Read
+
| Tool | What it does |
| --- | --- |
-| `search_issues_solutions` | Semantic + lexical search across all issues and solutions. |
+| `search_issues_solutions` | Semantic search across all issues and solutions. |
| `get_issue` | Fetch a single issue or solution by ID. |
-| `get_tree` | Walk the children of a node (issues and their solutions, recursively). |
+| `get_tree` | Walk the full descendant tree of a node (sub-issues, solutions, case studies). |
+| `get_case_study` / `list_case_studies` | Read case studies, individually or per solution. |
+| `suggest_more` | Find nodes similar to one you are looking at. |
+| `search_tags` | Search the tag taxonomy. |
+| `whoami` / `get_user` | Identify the authenticated user or fetch a public profile. |
+| `get_whitepaper` | The platform whitepaper (mission, principles, model) as markdown. |
+| `get_guide` | The [authoring guide](/guide/authoring): how to write and scope contributions. |
+
+### Write
+
+| Tool | What it does |
+| --- | --- |
| `create_issue` | Author a new top-level issue or sub-issue. |
| `create_solution` | Propose a solution under an existing issue. |
-| `update_issue` / `update_solution` | Edit a node you have permission to modify. |
-| `create_case_study` / `update_case_study` | Record a real-world implementation of a solution. |
-| `get_case_study` / `list_case_studies` | Read existing case studies. |
-| `suggest_more` | Get related issues or solutions for a node. |
-| `whoami` / `get_user` | Identify the authenticated user or fetch a public profile. |
-| `search_tags` | Tag autocomplete. |
+| `create_case_study` | Record a real-world implementation of a solution. |
+| `update_issue` / `update_solution` / `update_case_study` | Edit an existing node of that kind. |
+| `propose_edit` | Edit any node kind with one tool. |
+| `list_revisions` | A node's revision history plus visible pending proposals. |
+| `review_revision` | Approve or reject a pending proposal on a node you own. |
+
+Editing is collaborative, like a wiki: anyone can submit a change to any node. If you authored the node (or are an admin) the edit applies immediately; otherwise it is recorded as a pending revision proposal that the owner or an admin reviews. The response tells you which happened via `applied: true` or `applied: false`.
+
+Every issue and solution has two text fields: a required `summary` (plaintext, ≤280 characters, a standalone synopsis) and an optional `description` (markdown, no length limit). Case studies are structured: outcome, location, dates, metrics, cost, sources, lessons learned.
-Every issue and solution has two text fields: a required `summary` (plaintext, ≤280 characters) and an optional `description` (markdown, no length limit). Case studies are structured — outcome, location, dates, metrics, sources, lessons learned.
+All write tools accept an optional `model` field where AI clients pass the exact id of the model generating the content. New and edited content goes through AI moderation before it becomes visible, and writes are rate-limited per user. Before contributing, agents should read the [authoring guide](/guide/authoring) (also served by `get_guide`).
## What you're approving
-When you approve the connection, the client gets a bearer token tied to your account. While it's valid, the client can do anything you can do through the website — read public content, create issues and solutions in your name, edit your own nodes, attach case studies. It cannot change your account settings, see other users' private data, or impersonate other users.
+When you approve the connection, the client gets a bearer token tied to your account. While it's valid, the client can do anything you can do through the website: read public content, create issues and solutions in your name, edit or propose edits to nodes, attach case studies. It cannot change your account settings, see other users' private data, or impersonate other users.
Tokens last one hour and refresh transparently. Refresh tokens rotate on every use, so a leaked token stops working as soon as the legitimate client refreshes once.
## Revoking access
-There is no per-client revocation UI yet. If you need to cut a client off, sign out of CommunityFix and contact us — we can drop the row in `oauth_clients` for you, which cascades to all of that client's tokens.
+There is no per-client revocation UI yet. If you need to cut a client off, sign out of CommunityFix and contact us; we can drop the row in `oauth_clients` for you, which cascades to all of that client's tokens.
## Source
-The MCP implementation lives in [`server/utils/mcp-tools.ts`](https://github.com/mathix420/communityfix/blob/master/server/utils/mcp-tools.ts) and [`server/api/mcp/index.post.ts`](https://github.com/mathix420/communityfix/blob/master/server/api/mcp/index.post.ts). It is MIT-licensed like the rest of CommunityFix — feedback and PRs welcome.
+The MCP implementation lives in [`server/utils/mcp-tools.ts`](https://github.com/mathix420/communityfix/blob/master/server/utils/mcp-tools.ts) and [`server/api/mcp/index.post.ts`](https://github.com/mathix420/communityfix/blob/master/server/api/mcp/index.post.ts). It is MIT-licensed like the rest of CommunityFix; feedback and PRs welcome.
diff --git a/designs/brand.pen b/designs/brand.pen
new file mode 100644
index 0000000..fcc41d0
--- /dev/null
+++ b/designs/brand.pen
@@ -0,0 +1,9248 @@
+{
+ "version": "2.14",
+ "children": [
+ {
+ "type": "frame",
+ "id": "pMSfb",
+ "x": 0,
+ "y": 0,
+ "name": "Brand Guidelines",
+ "clip": true,
+ "width": 1520,
+ "fill": [
+ "$neutral-100",
+ {
+ "type": "shader",
+ "opacity": 0.1,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "gap": 40,
+ "padding": 40,
+ "children": [
+ {
+ "type": "frame",
+ "id": "O4LdY",
+ "name": "01 Cover",
+ "clip": true,
+ "width": "fill_container",
+ "fill": [
+ "$surface",
+ {
+ "type": "shader",
+ "opacity": 0.13,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 40,
+ "padding": 96,
+ "children": [
+ {
+ "type": "frame",
+ "id": "KE6dI",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "j4WUUZ",
+ "name": "Wordmark",
+ "fill": "$neutral-500",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "pAVog",
+ "name": "Doc Label",
+ "fill": "$neutral-400",
+ "content": "BRAND GUIDELINES",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Sg0NM",
+ "name": "Spacer T",
+ "width": "fill_container",
+ "height": 90
+ },
+ {
+ "type": "frame",
+ "id": "vbQNw",
+ "name": "Hero Block",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 24,
+ "children": [
+ {
+ "type": "text",
+ "id": "RoGn6",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "THE COMMUNITY-DRIVEN SOLUTIONS PLATFORM",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 4
+ },
+ {
+ "type": "frame",
+ "id": "L175hJ",
+ "name": "Logo Lockup",
+ "width": 640,
+ "height": 99.44223107569721,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "bhI6a",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$neutral-700",
+ "width": 640,
+ "height": 99.44223107569721
+ },
+ {
+ "type": "path",
+ "id": "svESW",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 640,
+ "height": 99.44223107569721
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "dtcCJ",
+ "name": "Gap",
+ "width": "fill_container",
+ "height": 16
+ },
+ {
+ "type": "text",
+ "id": "MPWvS",
+ "name": "Tagline",
+ "fill": "$ink",
+ "content": "Let's put our skills to work.",
+ "fontFamily": "$font-body",
+ "fontSize": 38,
+ "fontWeight": "600"
+ },
+ {
+ "type": "text",
+ "id": "P6fH4n",
+ "name": "Mission",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": 760,
+ "content": "An open-source platform dedicated to solving the world's issues together. Everyone — with a skill, a perspective, or a spark of an idea — can tackle small challenges that compound into global impact.",
+ "lineHeight": 1.6,
+ "fontFamily": "$font-body",
+ "fontSize": 18
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "FKVj2",
+ "name": "Spacer B",
+ "width": "fill_container",
+ "height": 90
+ },
+ {
+ "type": "frame",
+ "id": "a3MffG",
+ "name": "Meta Bar",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "uRSIX",
+ "name": "VERSION",
+ "layout": "vertical",
+ "gap": 6,
+ "children": [
+ {
+ "type": "text",
+ "id": "tsU2k",
+ "name": "Key",
+ "fill": "$neutral-400",
+ "content": "VERSION",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "Xbcc0",
+ "name": "Val",
+ "fill": "$neutral-700",
+ "content": "1.0",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "bJjt5",
+ "name": "UPDATED",
+ "layout": "vertical",
+ "gap": 6,
+ "children": [
+ {
+ "type": "text",
+ "id": "vrhJl",
+ "name": "Key",
+ "fill": "$neutral-400",
+ "content": "UPDATED",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "H1G0Vi",
+ "name": "Val",
+ "fill": "$neutral-700",
+ "content": "June 2026",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "BCuWK",
+ "name": "MAINTAINER",
+ "layout": "vertical",
+ "gap": 6,
+ "children": [
+ {
+ "type": "text",
+ "id": "YhRvc",
+ "name": "Key",
+ "fill": "$neutral-400",
+ "content": "MAINTAINER",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "Xtx60",
+ "name": "Val",
+ "fill": "$neutral-700",
+ "content": "Open-source community",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "yGb7F",
+ "name": "02 Logo & Wordmark",
+ "clip": true,
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 48,
+ "padding": 72,
+ "children": [
+ {
+ "type": "frame",
+ "id": "hZgkW",
+ "name": "Header",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "Ps7d1",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "IDENTITY",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ },
+ {
+ "type": "frame",
+ "id": "la4kO",
+ "name": "Title",
+ "children": [
+ {
+ "type": "frame",
+ "id": "Qlyda",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "YrGU4",
+ "name": "t",
+ "fill": "$ink",
+ "content": "Lo",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "WACAb",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "g",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "o7d6Vt",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "DdDTa",
+ "name": "t",
+ "fill": "$ink",
+ "content": "o & Wordmark",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "oqGTG",
+ "name": "Primary Wordmark",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "okFZ9",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "PRIMARY WORDMARK",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "frame",
+ "id": "ZDFZn",
+ "name": "Panel",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "padding": [
+ 76,
+ 48
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "Nh5bw",
+ "name": "Logo Lockup",
+ "width": 480,
+ "height": 74.58,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "t4S0bO",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$neutral-700",
+ "width": 480,
+ "height": 74.58
+ },
+ {
+ "type": "path",
+ "id": "Tr8Tf",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 480,
+ "height": 74.58
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "HW1QE",
+ "name": "Caption",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The brand mark is a text wordmark — no pictorial logo. Set in Oswald, all lowercase, always keeping the .org.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "M3Wh29",
+ "name": "Inverse Wordmark",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "jmJqY",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "INVERSE — ON DARK",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "frame",
+ "id": "pACmD",
+ "name": "Dark Panel",
+ "width": "fill_container",
+ "fill": "$ink",
+ "cornerRadius": "$radius-lg",
+ "padding": [
+ 76,
+ 48
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "JN6nt",
+ "name": "Logo Lockup",
+ "width": 480,
+ "height": 74.58167330677291,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "gmjK6",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "#ffffff",
+ "width": 480,
+ "height": 74.58167330677291
+ },
+ {
+ "type": "path",
+ "id": "Gkzpo",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 480,
+ "height": 74.58167330677291
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "G22i2T",
+ "name": "Caption",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "On near-black and dark imagery the wordmark reverses to pure white. Keep full contrast — never tint it.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "XLnyn",
+ "name": "Interactive Underline",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "y4Eeu",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "INTERACTIVE UNDERLINE",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "text",
+ "id": "oYGPX",
+ "name": "Caption",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "A signature interaction — on hover or active state the wordmark gains a blue underline.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "frame",
+ "id": "r10QN",
+ "name": "Panel",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 24,
+ "padding": [
+ 64,
+ 48
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "X6rS5y",
+ "name": "Mark Group",
+ "layout": "vertical",
+ "gap": 14,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "W6Cd1U",
+ "name": "Logo Lockup",
+ "width": 360,
+ "height": 55.93625498007968,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "W0tPDU",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$neutral-700",
+ "width": 360,
+ "height": 55.93625498007968
+ },
+ {
+ "type": "path",
+ "id": "Ds2dD",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 360,
+ "height": 55.93625498007968
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "IYrDL",
+ "name": "State Label",
+ "fill": "$primary-600",
+ "content": "hover / active state",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal",
+ "fontStyle": "italic"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "h3EQi",
+ "name": "Clear Space & Min Size",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "fCmDF",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "CLEAR SPACE & MINIMUM SIZE",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "frame",
+ "id": "P5rFyv",
+ "name": "Columns",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "lXqC0",
+ "name": "Clear Space",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "layout": "vertical",
+ "gap": 16,
+ "padding": 28,
+ "children": [
+ {
+ "type": "text",
+ "id": "mhXAN",
+ "name": "Sub Label",
+ "fill": "$ink",
+ "content": "CLEAR SPACE",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "600",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "duqba",
+ "name": "Body",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Keep clear space around the wordmark on all sides equal to the cap height of its letters. Don't crowd it with other elements.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "frame",
+ "id": "qRtPF",
+ "name": "Visual",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$primary-300",
+ "strokeWidth": 1,
+ "padding": 26,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "GKFvJ",
+ "name": "Mark Box",
+ "fill": "$primary-50",
+ "cornerRadius": 4,
+ "padding": [
+ 8,
+ 12
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "qYTyy",
+ "name": "Logo Lockup",
+ "width": 200,
+ "height": 31.07569721115538,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "aIqXL",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$neutral-700",
+ "width": 200,
+ "height": 31.07569721115538
+ },
+ {
+ "type": "path",
+ "id": "EC1b9",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 200,
+ "height": 31.07569721115538
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "kQHwf",
+ "name": "Note",
+ "fill": "$text-muted",
+ "content": "Padding shown = cap height of the wordmark.",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "gmXsZ",
+ "name": "Minimum Size",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "layout": "vertical",
+ "gap": 16,
+ "padding": 28,
+ "children": [
+ {
+ "type": "text",
+ "id": "GC8Ct",
+ "name": "Sub Label",
+ "fill": "$ink",
+ "content": "MINIMUM SIZE",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "600",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "MwdB6",
+ "name": "Body",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Never reproduce the wordmark below 120px wide on screen, or 24px tall in print. Below that, switch to the compact mark.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "frame",
+ "id": "A5UuD",
+ "name": "Visual",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 10,
+ "padding": 26,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "TlHlL",
+ "name": "Logo Lockup",
+ "width": 120,
+ "height": 18.64541832669323,
+ "layout": "none",
+ "children": [
+ {
+ "type": "path",
+ "id": "fyXNO",
+ "x": 0,
+ "y": 0,
+ "name": "Wordmark",
+ "geometry": "M8.47997 31.52c-1.89333 0-3.33333-0.3733-4.32-1.12-0.96-0.7733-1.61333-1.8533-1.96-3.24-0.32-1.3867-0.48-3-0.48-4.84v-5.32c0-1.9467 0.17333-3.6 0.52-4.96 0.34667-1.3867 1-2.44 1.96-3.16 0.98667-0.74667 2.41333-1.12 4.28-1.12 1.68003 0 2.97333 0.28 3.88003 0.84 0.9333 0.53333 1.5733 1.34666 1.92 2.44 0.3467 1.0667 0.52 2.4 0.52 4v1.36h-3.88v-1.4c0-1.0133-0.0667-1.8133-0.2-2.4-0.1333-0.5867-0.3733-1.0133-0.72003-1.28-0.34667-0.2667-0.84-0.4-1.48-0.4-0.69333 0-1.22667 0.16-1.6 0.48-0.37333 0.32-0.62667 0.8667-0.76 1.64-0.10667 0.7467-0.16 1.7733-0.16 3.08v7.28c0 1.9733 0.18667 3.2933 0.56 3.96 0.37333 0.6667 1.04 1 2 1 0.72 0 1.24-0.16 1.56003-0.48 0.3467-0.3467 0.56-0.8267 0.64-1.44 0.1067-0.6133 0.16-1.3067 0.16-2.08v-1.88h3.88v1.6c0 1.5467-0.1867 2.88-0.56 4-0.3467 1.12-0.9867 1.9733-1.92 2.56-0.9067 0.5867-2.1867 0.88-3.84003 0.88z m16.29003 0c-1.7067 0-3.0533-0.3467-4.04-1.04-0.9867-0.6933-1.6933-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.4267-1.28 1.1333-2.26667 2.12-2.96 0.9867-0.69334 2.3333-1.04 4.04-1.04 1.7067 0 3.04 0.34666 4 1.04 0.9867 0.69333 1.68 1.68 2.08 2.96 0.4267 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.2133 3.24-0.64 4.52-0.4 1.28-1.0933 2.2667-2.08 2.96-0.96 0.6933-2.2933 1.04-4 1.04z m0-3.16c0.8267 0 1.4-0.24 1.72-0.72 0.3467-0.48 0.5467-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.0533-0.7733-0.2533-1.4-0.6-1.88-0.32-0.5067-0.8933-0.76-1.72-0.76-0.8267 0-1.4133 0.2533-1.76 0.76-0.3467 0.48-0.56 1.1067-0.64 1.88-0.0533 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.0267 1.68 0.08 2.48 0.08 0.7733 0.2933 1.4 0.64 1.88 0.3467 0.48 0.9333 0.72 1.76 0.72z m10.6322 2.84v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m27.1875 0v-23.12h3.96v2.2c0.7733-0.88 1.6267-1.53334 2.56-1.96 0.9333-0.42667 1.8933-0.64 2.88-0.64 0.8533 0 1.6133 0.21333 2.28 0.64 0.6933 0.42666 1.1867 1.18666 1.48 2.28 0.8267-1.01334 1.72-1.74667 2.68-2.20001 0.9867-0.47999 2.0133-0.71999 3.08-0.71999 0.7467 0 1.4267 0.18666 2.04 0.56 0.6133 0.34666 1.1067 0.92 1.48 1.72 0.3733 0.7733 0.56 1.8 0.56 3.08v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.64 0.32-1.2267 0.7733-1.76 1.36 0 0.1067 0 0.2133 0 0.32 0 0.1067 0 0.2267 0 0.36v18.16h-3.92v-17.88c0-1.12-0.1867-1.8667-0.56-2.24-0.3467-0.4-0.8267-0.6-1.44-0.6-0.5867 0-1.2 0.1733-1.84 0.52-0.6133 0.32-1.2 0.7733-1.76 1.36v18.84h-3.96z m30.5075 0.32c-0.9067 0-1.6133-0.24-2.12-0.72-0.5067-0.48-0.8667-1.08-1.08-1.8-0.1867-0.72-0.28-1.4533-0.28-2.2v-18.72h4.28v17.44c0 0.8267 0.12 1.48 0.36 1.96 0.24 0.4533 0.7333 0.68 1.48 0.68 0.48 0 0.9733-0.1333 1.48-0.4 0.5333-0.2933 1.04-0.64 1.52-1.04v-18.64h4.2798v23.12h-4.2798v-2.28c-0.7467 0.72-1.6 1.3333-2.56 1.84-0.9333 0.5067-1.96 0.76-3.08 0.76z m14.2968-0.32v-23.12h4.28v2.28c0.774-0.72 1.627-1.33334 2.56-1.84 0.934-0.50667 1.96-0.76 3.08-0.76 0.934 0 1.64 0.24 2.12 0.72 0.507 0.48 0.854 1.08 1.04 1.8 0.214 0.72 0.32 1.4533 0.32 2.2v18.72h-4.28v-17.48c0-0.8267-0.12-1.4667-0.36-1.92-0.24-0.4533-0.733-0.68-1.48-0.68-0.48 0-0.986 0.1467-1.52 0.44-0.506 0.2667-1 0.6-1.48 1v18.64h-4.28z m17.859 0v-23.12h4.28v23.12h-4.28z m0-26.56v-4.48h4.28v4.48h-4.28z m15.02 26.88c-1.2 0-2.173-0.2267-2.92-0.68-0.72-0.4533-1.253-1.0933-1.6-1.92-0.32-0.8267-0.48-1.8-0.48-2.92v-15.08h-2.44v-2.84h2.44v-6.76h4.28v6.76h3.72v2.84h-3.72v14.8c0 0.9067 0.147 1.56 0.44 1.96 0.293 0.3733 0.853 0.56 1.68 0.56 0.213 0 0.453-0.0133 0.72-0.04 0.293-0.0267 0.573-0.0533 0.84-0.08v3.16c-0.533 0.08-1.027 0.1333-1.48 0.16-0.453 0.0533-0.947 0.08-1.48 0.08z m5.785 5.48v-3.28c1.04 0 1.866-0.0533 2.48-0.16 0.613-0.08 1.053-0.2533 1.32-0.52 0.266-0.24 0.4-0.5867 0.4-1.04 0-0.24-0.094-0.7733-0.28-1.6-0.187-0.8267-0.387-1.7067-0.6-2.64l-4.64-19.68h4.32l3.12 17.28 3-17.28h4.32l-5.52 24.4c-0.24 1.12-0.68 2-1.32 2.64-0.64 0.6667-1.467 1.1467-2.48 1.44-0.987 0.2933-2.187 0.44-3.6 0.44h-0.52z m17.384-5.8v-19.96h-2.76v-3.16h2.76v-1.2c0-1.54667 0.16-2.82667 0.48-3.84001 0.347-1.01333 0.92-1.77333 1.72-2.27999 0.8-0.50667 1.893-0.76 3.28-0.76 0.347 0 0.707 0.01333 1.08 0.04 0.4 0.02667 0.813 0.06667 1.24 0.12v3.2c-0.267-0.05334-0.533-0.09334-0.8-0.12001-0.24-0.02666-0.467-0.03999-0.68-0.03999-0.827 0-1.373 0.22666-1.64 0.68-0.267 0.45333-0.4 1.21333-0.4 2.28v1.92h9.44v23.12h-4.28v-19.96h-5.16v19.96h-4.28z m9.84-26.56v-4.48h3.92v4.48h-3.92z m6.648 26.56l5.2-12.16-5.08-10.96h4.08l3.4 7.32 3-7.32h4.2l-5.28 11.48 5.36 11.64h-4.08l-3.64-7.84-3.12 7.84h-4.04z m17.147 0v-4.76h4.28v4.76h-4.28z m13.826 0.32c-1.707 0-3.054-0.3467-4.04-1.04-0.987-0.6933-1.694-1.68-2.12-2.96-0.4-1.28-0.6-2.7867-0.6-4.52v-6.72c0-1.7333 0.2-3.24 0.6-4.52 0.426-1.28 1.133-2.26667 2.12-2.96 0.986-0.69334 2.333-1.04 4.04-1.04 1.706 0 3.04 0.34666 4 1.04 0.986 0.69333 1.68 1.68 2.08 2.96 0.426 1.28 0.64 2.7867 0.64 4.52v6.72c0 1.7333-0.214 3.24-0.64 4.52-0.4 1.28-1.094 2.2667-2.08 2.96-0.96 0.6933-2.294 1.04-4 1.04z m0-3.16c0.826 0 1.4-0.24 1.72-0.72 0.346-0.48 0.546-1.1067 0.6-1.88 0.08-0.8 0.12-1.6267 0.12-2.48v-7.24c0-0.88-0.04-1.7067-0.12-2.48-0.054-0.7733-0.254-1.4-0.6-1.88-0.32-0.5067-0.894-0.76-1.72-0.76-0.827 0-1.414 0.2533-1.76 0.76-0.347 0.48-0.56 1.1067-0.64 1.88-0.054 0.7733-0.08 1.6-0.08 2.48v7.24c0 0.8533 0.026 1.68 0.08 2.48 0.08 0.7733 0.293 1.4 0.64 1.88 0.346 0.48 0.933 0.72 1.76 0.72z m10.752 2.84v-23.12h4.28v3.28c0.853-1.41334 1.707-2.36 2.56-2.84 0.853-0.48 1.707-0.72 2.56-0.72 0.107 0 0.2 0 0.28 0 0.107 0 0.24 0.01333 0.4 0.03999v4.72001c-0.32-0.1333-0.693-0.24-1.12-0.32-0.4-0.1067-0.813-0.16-1.24-0.16-0.667 0-1.28 0.16-1.84 0.48-0.533 0.2933-1.067 0.88-1.6 1.76v16.88h-4.28z m18.617 7.24c-1.413 0-2.693-0.16-3.84-0.48-1.12-0.2933-2.013-0.76-2.68-1.4-0.64-0.64-0.96-1.48-0.96-2.52 0-0.8 0.187-1.5067 0.56-2.12 0.374-0.5867 0.854-1.0933 1.44-1.52 0.587-0.4267 1.2-0.76 1.84-1l2 0.28c-0.346 0.32-0.68 0.6533-1 1-0.32 0.32-0.586 0.6533-0.8 1-0.213 0.3733-0.32 0.7733-0.32 1.2 0 0.6933 0.294 1.2133 0.88 1.56 0.614 0.3467 1.694 0.52 3.24 0.52 1.52 0 2.64-0.1867 3.36-0.56 0.747-0.3467 1.12-0.9067 1.12-1.68 0-0.48-0.093-0.88-0.28-1.2-0.186-0.2933-0.506-0.5333-0.96-0.72-0.453-0.1867-1.106-0.32-1.96-0.4l-5.04-0.64c-1.04-0.1333-1.813-0.4667-2.32-1-0.506-0.5333-0.76-1.12-0.76-1.76 0-0.96 0.267-1.7733 0.8-2.44 0.56-0.6667 1.307-1.4133 2.24-2.24l1.44 1.12c-0.16 0.2133-0.36 0.52-0.6 0.92-0.24 0.3733-0.36 0.7467-0.36 1.12 0 0.2933 0.147 0.5333 0.44 0.72 0.32 0.1867 0.814 0.3067 1.48 0.36l3.88 0.36c1.814 0.16 3.16 0.7067 4.04 1.64 0.88 0.9333 1.32 2.2667 1.32 4 0 1.28-0.306 2.36-0.92 3.24-0.586 0.88-1.48 1.5333-2.68 1.96-1.2 0.4533-2.733 0.68-4.6 0.68z m0.16-14.36c-1.573 0-2.853-0.3467-3.84-1.04-0.986-0.6933-1.72-1.6533-2.2-2.88-0.453-1.2267-0.68-2.6267-0.68-4.2 0-1.7867 0.254-3.28 0.76-4.48 0.507-1.2267 1.254-2.14667 2.24-2.76 1.014-0.64 2.254-0.96 3.72-0.96 1.52 0 2.76 0.38666 3.72 1.16 0.96 0.74666 1.667 1.7467 2.12 3 0.454 1.2533 0.68 2.64 0.68 4.16 0 1.5733-0.213 2.9733-0.64 4.2-0.4 1.2-1.08 2.1333-2.04 2.8-0.933 0.6667-2.213 1-3.84 1z m0-3.08c0.587 0 1.054-0.1067 1.4-0.32 0.347-0.24 0.6-0.5733 0.76-1 0.187-0.4267 0.307-0.9467 0.36-1.56 0.08-0.64 0.12-1.36 0.12-2.16 0-0.8-0.026-1.52-0.08-2.16-0.053-0.64-0.173-1.1733-0.36-1.6-0.186-0.4533-0.453-0.8-0.8-1.04-0.346-0.24-0.8-0.36-1.36-0.36-0.586 0-1.066 0.12-1.44 0.36-0.346 0.2133-0.626 0.5467-0.84 1-0.186 0.4267-0.32 0.96-0.4 1.6-0.053 0.64-0.08 1.3733-0.08 2.2 0 0.72 0.027 1.3867 0.08 2 0.08 0.6133 0.214 1.1467 0.4 1.6 0.187 0.4533 0.454 0.8133 0.8 1.08 0.374 0.24 0.854 0.36 1.44 0.36z m5.72-9.04l-1-1.6c0.214-0.37334 0.694-0.84 1.44-1.40001 0.747-0.55999 1.547-1.02666 2.4-1.39999l1.12 2.8c-0.373 0.08-0.88 0.2267-1.52 0.44-0.613 0.2133-1.173 0.44-1.68 0.68-0.506 0.2133-0.76 0.3733-0.76 0.48z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$neutral-700",
+ "width": 120,
+ "height": 18.64541832669323
+ },
+ {
+ "type": "path",
+ "id": "JLdqT",
+ "x": 0,
+ "y": 0,
+ "name": "Underline",
+ "geometry": "M0 33.76h142v3.2h-142v-3.2z m158 0h71v3.2h-71v-3.2z",
+ "viewBox": [
+ 0,
+ 0,
+ 251,
+ 39
+ ],
+ "fill": "$primary-500",
+ "width": 120,
+ "height": 18.64541832669323
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "uNMEz",
+ "name": "Note",
+ "fill": "$text-muted",
+ "content": "120px — minimum legible width",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "IeUcd",
+ "name": "Compact Mark",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "SXNaD",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "COMPACT MARK / FAVICON",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "text",
+ "id": "d3CCk",
+ "name": "Caption",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "A favicon stands in as the compact mark in browser tabs and app icons. It carries the lowercase 'c' in Oswald with the signature blue accent.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "frame",
+ "id": "OOHRa",
+ "name": "Panel",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "gap": 36,
+ "padding": [
+ 44,
+ 48
+ ],
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "frame",
+ "id": "cw2nz",
+ "name": "Tile Cell",
+ "layout": "vertical",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "inkOB",
+ "name": "Tile",
+ "clip": true,
+ "width": 88,
+ "height": 88,
+ "fill": [
+ "#ffffff",
+ {
+ "type": "image",
+ "enabled": true,
+ "url": "../public/favicon-96x96.png",
+ "mode": "fit"
+ }
+ ],
+ "cornerRadius": 20,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 5,
+ "justifyContent": "center",
+ "alignItems": "center"
+ },
+ {
+ "type": "text",
+ "id": "f5D23",
+ "name": "Sz",
+ "fill": "$text-muted",
+ "content": "App icon",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "RgdFw",
+ "name": "Tile Cell",
+ "layout": "vertical",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "kWojN",
+ "name": "Tile",
+ "clip": true,
+ "width": 48,
+ "height": 48,
+ "fill": [
+ "#ffffff",
+ {
+ "type": "image",
+ "enabled": true,
+ "url": "../public/favicon-96x96.png",
+ "mode": "fit"
+ }
+ ],
+ "cornerRadius": 10,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 5,
+ "justifyContent": "center",
+ "alignItems": "center"
+ },
+ {
+ "type": "text",
+ "id": "mpzWy",
+ "name": "Sz",
+ "fill": "$text-muted",
+ "content": "Browser tab",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Df5zd",
+ "name": "Tile Cell",
+ "layout": "vertical",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "bbYby",
+ "name": "Tile",
+ "clip": true,
+ "width": 28,
+ "height": 28,
+ "fill": [
+ "#ffffff",
+ {
+ "type": "image",
+ "enabled": true,
+ "url": "../public/favicon-96x96.png",
+ "mode": "fit"
+ }
+ ],
+ "cornerRadius": 6,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "justifyContent": "center",
+ "alignItems": "center"
+ },
+ {
+ "type": "text",
+ "id": "m03kJc",
+ "name": "Sz",
+ "fill": "$text-muted",
+ "content": "16px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "I7qXJ",
+ "name": "Tile Cell",
+ "layout": "vertical",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "LED6a",
+ "name": "Tile",
+ "clip": true,
+ "width": 88,
+ "height": 88,
+ "fill": [
+ "#ffffff",
+ {
+ "type": "image",
+ "enabled": true,
+ "url": "../public/favicon-96x96.png",
+ "mode": "fit"
+ }
+ ],
+ "cornerRadius": 20,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 5,
+ "justifyContent": "center",
+ "alignItems": "center"
+ },
+ {
+ "type": "text",
+ "id": "kjly9",
+ "name": "Sz",
+ "fill": "$text-muted",
+ "content": "Maskable",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "DYIvC",
+ "name": "Do and Dont",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "LnmqM",
+ "name": "Label",
+ "fill": "$primary-600",
+ "content": "DO & DON'T",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "600",
+ "letterSpacing": 2.5
+ },
+ {
+ "type": "frame",
+ "id": "LnF6x",
+ "name": "Columns",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "FOUUL",
+ "name": "DO",
+ "width": "fill_container",
+ "fill": "$primary-50",
+ "cornerRadius": "$radius-lg",
+ "layout": "vertical",
+ "gap": 18,
+ "padding": 28,
+ "children": [
+ {
+ "type": "text",
+ "id": "pqtqu",
+ "name": "Heading",
+ "fill": "$primary-700",
+ "content": "DO",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "frame",
+ "id": "onuAx",
+ "name": "List",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 13,
+ "children": [
+ {
+ "type": "frame",
+ "id": "otHQe",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "HAPqO",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "k4U7Zm",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Keep it lowercase — communityfix.org",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "sO41T",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "C5RUC7",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "CFMBh",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Set it in Oswald, the display typeface",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "FdBiY",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "Gm4xZ",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "nW2VD",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Always keep the .org",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "UK8l5",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "pJ4eM",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "vLVIg",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Use it in solid black or pure white",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "V4IXV3",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "pWTRs",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "U8WkJt",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Give it room to breathe",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "tMGBq",
+ "name": "DON'T",
+ "width": "fill_container",
+ "fill": "$neutral-100",
+ "cornerRadius": "$radius-lg",
+ "layout": "vertical",
+ "gap": 18,
+ "padding": 28,
+ "children": [
+ {
+ "type": "text",
+ "id": "N1GYv",
+ "name": "Heading",
+ "fill": "$neutral-600",
+ "content": "DON'T",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "frame",
+ "id": "qbU5z",
+ "name": "List",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 13,
+ "children": [
+ {
+ "type": "frame",
+ "id": "vwuSy",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "a9Rkv",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "r1u75",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Capitalize it or use title case",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "d4yK6",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "RsU7f",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "q7vqk",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Swap the typeface for something else",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "M5kYW",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "p1PQwt",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "R3Xr4J",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Drop the .org from the wordmark",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "J4c41",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "Tszzs",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "hVV0E",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Stretch, condense, or recolor it arbitrarily",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "WzoO6",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 12,
+ "children": [
+ {
+ "type": "icon",
+ "id": "KQ7HD",
+ "name": "Icon",
+ "width": 19,
+ "height": 19,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "g3cN7j",
+ "name": "Text",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Add drop shadows, gradients, or effects",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "y0VaM",
+ "name": "03 Color Palette",
+ "clip": true,
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 48,
+ "padding": 72,
+ "children": [
+ {
+ "type": "frame",
+ "id": "iFz05",
+ "name": "Header",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "c0Leq",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "COLOR",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ },
+ {
+ "type": "frame",
+ "id": "NjrRG",
+ "name": "Title",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "FKIkl",
+ "name": "Title Text",
+ "fill": "$ink",
+ "content": "Color Palette",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "VErcJ",
+ "name": "Primary Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "d7o69D",
+ "name": "Section Head",
+ "width": "fill_container",
+ "gap": 48,
+ "justifyContent": "space_between",
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "frame",
+ "id": "bk2zJ",
+ "name": "Title Wrap",
+ "gap": 10,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "BOxFA",
+ "name": "Title Main",
+ "fill": "$ink",
+ "content": "Primary",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "u6c4dM",
+ "name": "Title Sub",
+ "fill": "$text-muted",
+ "content": "Blue",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "sNZqi",
+ "name": "Usage",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": 560,
+ "content": "The core brand color — used for links, interactive states, focus rings and primary CTAs. 500 is the base brand blue; 600 drives primary buttons and their hover state.",
+ "lineHeight": 1.45,
+ "textAlign": "right",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "O5Vba4",
+ "name": "Swatch Row",
+ "width": "fill_container",
+ "gap": 8,
+ "children": [
+ {
+ "type": "frame",
+ "id": "QTe0A",
+ "name": "Cell 50",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "Bkwxx",
+ "name": "Chip",
+ "fill": "$primary-50",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "ZfajQ",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "dEr4j",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "50",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "Hsorw",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#eff6ff",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "lAHCd",
+ "name": "Cell 100",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "x9rbIZ",
+ "name": "Chip",
+ "fill": "$primary-100",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "LlYnL",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "cCsoR",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "100",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "KYRPp",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#dbeafe",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "yVNWq",
+ "name": "Cell 200",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "FLaC1",
+ "name": "Chip",
+ "fill": "$primary-200",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "NaLuM",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "J8exg",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "200",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "K18vDU",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#bfdbfe",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "wlu4l",
+ "name": "Cell 300",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "e0bLZs",
+ "name": "Chip",
+ "fill": "$primary-300",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "L9at8",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "yrOPI",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "300",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "b2Ila7",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#93c5fd",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "p4ofY",
+ "name": "Cell 400",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "k0IEb",
+ "name": "Chip",
+ "fill": "$primary-400",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "S2u6J",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "WWMkE",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "400",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "bb4og",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#60a5fa",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "OJTEg",
+ "name": "Cell 500",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "Ke0E6",
+ "name": "Chip",
+ "fill": "$primary-500",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "lhF18",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "Glv8E",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "500",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "S3ORoP",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#3b82f6",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "VfDjx",
+ "name": "Cell 600",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "pMp75",
+ "name": "Chip",
+ "fill": "$primary-600",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "v4qxGD",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "E4uS7",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "600",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "o3oDb2",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#2563eb",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Epyea",
+ "name": "Cell 700",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "UorAo",
+ "name": "Chip",
+ "fill": "$primary-700",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "kDYhV",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "UF1Fz",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "700",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "ugMOk",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#1d4ed8",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "hB2jS",
+ "name": "Cell 800",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "f8Ahn",
+ "name": "Chip",
+ "fill": "$primary-800",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "mVUzq",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "yR6Qt",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "800",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "Q2nxK",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#1e40af",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "UG5od",
+ "name": "Cell 900",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "tNYRS",
+ "name": "Chip",
+ "fill": "$primary-900",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "E6vey",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "W8cNz",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "900",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "weANy",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#1e3a8a",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "J4idtU",
+ "name": "Cell 950",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "mBbQr",
+ "name": "Chip",
+ "fill": "$primary-950",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "nxn6A",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "e7rTv0",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "950",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "QTv4N",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#172554",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Y7j7yT",
+ "name": "Neutral Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "pOIc2",
+ "name": "Section Head",
+ "width": "fill_container",
+ "gap": 48,
+ "justifyContent": "space_between",
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "frame",
+ "id": "D3J5mO",
+ "name": "Title Wrap",
+ "gap": 10,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "C8M9v",
+ "name": "Title Main",
+ "fill": "$ink",
+ "content": "Neutral",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "d6CcUH",
+ "name": "Title Sub",
+ "fill": "$text-muted",
+ "content": "Grayscale",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "ihOPL",
+ "name": "Usage",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": 560,
+ "content": "The workhorse scale for text, surfaces and borders. The default text color is pure black (950 / #0a0a0a), with lighter steps for secondary text, dividers and recessed panels.",
+ "lineHeight": 1.45,
+ "textAlign": "right",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "LEF1C",
+ "name": "Swatch Row",
+ "width": "fill_container",
+ "gap": 8,
+ "children": [
+ {
+ "type": "frame",
+ "id": "wAPkQ",
+ "name": "Cell 50",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "RrH2j",
+ "name": "Chip",
+ "fill": "$neutral-50",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "E9eSmc",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "M7aRN",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "50",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "WytHh",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#fafafa",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "LJiHE",
+ "name": "Cell 100",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "QkFjU",
+ "name": "Chip",
+ "fill": "$neutral-100",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "foxG8",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "NSvXJ",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "100",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "m8sFj2",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#f5f5f5",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "kzR25",
+ "name": "Cell 200",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "RugAO",
+ "name": "Chip",
+ "fill": "$neutral-200",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "LUl0V",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "QUcja",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "200",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "O5vaNj",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#e5e5e5",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "e2xZFT",
+ "name": "Cell 300",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "Q32qxf",
+ "name": "Chip",
+ "fill": "$neutral-300",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "t28flr",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "b75Ym",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "300",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "t4eFme",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#d4d4d4",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "M7CMY",
+ "name": "Cell 400",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "d3nUi",
+ "name": "Chip",
+ "fill": "$neutral-400",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "bvhZV",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "Y0sT2",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "400",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "ztAys",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#a3a3a3",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "ipXPv",
+ "name": "Cell 500",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "HYWK1",
+ "name": "Chip",
+ "fill": "$neutral-500",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "On6Et",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "Z4bMo",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "500",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "c1kMLw",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#737373",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "F64Zp8",
+ "name": "Cell 600",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "Asx8N",
+ "name": "Chip",
+ "fill": "$neutral-600",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "KiEAa",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "N7FxHA",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "600",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "gGykA",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#525252",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "zhRsP",
+ "name": "Cell 700",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "v7OTB",
+ "name": "Chip",
+ "fill": "$neutral-700",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "u550D",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "r7lyXl",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "700",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "yf7Ey",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#404040",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "C0HFy",
+ "name": "Cell 800",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "r500A",
+ "name": "Chip",
+ "fill": "$neutral-800",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "DpedX",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "c2g7a",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "800",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "K5JY0",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#262626",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "k266B",
+ "name": "Cell 900",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "HQd2T",
+ "name": "Chip",
+ "fill": "$neutral-900",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "LZfaj",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "Vhbww",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "900",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "q0FxIR",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#171717",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "u4s98J",
+ "name": "Cell 950",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "iNH4w",
+ "name": "Chip",
+ "fill": "$neutral-950",
+ "width": "fill_container",
+ "height": 96,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "W4Vr4p",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "s7cmR",
+ "name": "Step",
+ "fill": "$text-primary",
+ "content": "950",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "yzTlK",
+ "name": "Hex",
+ "fill": "$text-muted",
+ "content": "#0a0a0a",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "nXgXA",
+ "name": "Semantic Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "hhxD0",
+ "name": "Section Head",
+ "width": "fill_container",
+ "gap": 48,
+ "justifyContent": "space_between",
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "frame",
+ "id": "mtnkU",
+ "name": "Title Wrap",
+ "gap": 10,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "KHMKQ",
+ "name": "Title Main",
+ "fill": "$ink",
+ "content": "Semantic",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "V9ttm",
+ "name": "Title Sub",
+ "fill": "$text-muted",
+ "content": "Tokens",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "onowk",
+ "name": "Usage",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": 560,
+ "content": "Role-based aliases that map intent to a raw value. Reference these in product UI instead of raw scale steps so themes stay consistent.",
+ "lineHeight": 1.45,
+ "textAlign": "right",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Oy4Rz",
+ "name": "Token Grid",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "frame",
+ "id": "Hm7iQ",
+ "name": "Grid Row",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "l9EOQ",
+ "name": "Token Text Primary",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "Pzb88",
+ "name": "Chip",
+ "fill": "$text-primary",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "ul4s8",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "djUsc",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Text Primary",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "edmvv",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "gNRhs",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#000000",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "Y1jTrl",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "mTwrt",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Default body text",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "pAWcM",
+ "name": "Token Text Secondary",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "XTXTc",
+ "name": "Chip",
+ "fill": "$text-secondary",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "c5cMLu",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "f7K6t",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Text Secondary",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "s2tEd",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "XZWka",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#525252",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "KBS6k",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "juDWw",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Supporting text & captions",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "ExOvz",
+ "name": "Grid Row",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "yEm3X",
+ "name": "Token Text Muted",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "dr383",
+ "name": "Chip",
+ "fill": "$text-muted",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "yp3um",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "t7aiL",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Text Muted",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "r43Fs",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "BlrsP",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#a3a3a3",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "LlZrN",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "NYI03",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Hints & placeholders",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "J4ggiB",
+ "name": "Token Border",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "TUToI",
+ "name": "Chip",
+ "fill": "$border",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "I4d8EB",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "MXcfg",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Border",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "Q8Ap8",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "OfBeb",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#e5e5e5",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "s8sEn",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "wZ5WE",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Dividers & outlines",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "J3VZp",
+ "name": "Grid Row",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "L97Rxv",
+ "name": "Token Surface",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "jdEIq",
+ "name": "Chip",
+ "fill": "$surface",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "tZ4EE",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "Vn2rN",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Surface",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "PBbno",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "eUuBe",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#ffffff",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "j9HOp",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "kZ8hA",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Page & card background",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "w54H5",
+ "name": "Token Surface Subtle",
+ "width": "fill_container",
+ "gap": 16,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "rectangle",
+ "cornerRadius": "$radius-md",
+ "id": "F6hYB3",
+ "name": "Chip",
+ "fill": "$surface-subtle",
+ "width": 52,
+ "height": 52,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner"
+ },
+ {
+ "type": "frame",
+ "id": "a6kk5",
+ "name": "Text Wrap",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "KQzVr",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Surface Subtle",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "c6iAwU",
+ "name": "Sub",
+ "gap": 8,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "LEA2a",
+ "name": "Hex",
+ "fill": "$text-secondary",
+ "content": "#fafafa",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "LIOtT",
+ "name": "Dot",
+ "fill": "$text-muted",
+ "content": "·",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "text",
+ "id": "ksG7I",
+ "name": "Desc",
+ "fill": "$text-muted",
+ "content": "Recessed panels",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "HklDG",
+ "name": "04 Typography",
+ "clip": true,
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 48,
+ "padding": 72,
+ "children": [
+ {
+ "type": "frame",
+ "id": "E5urDW",
+ "name": "Header",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "zWEYm",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "TYPE SYSTEM",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ },
+ {
+ "type": "frame",
+ "id": "SL2vV",
+ "name": "Title",
+ "children": [
+ {
+ "type": "frame",
+ "id": "D3i0Q",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "snfrp",
+ "name": "t",
+ "fill": "$ink",
+ "content": "T",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "VCWvu",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "yp",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "pLuzj",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "cGP4i",
+ "name": "t",
+ "fill": "$ink",
+ "content": "o",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "i2soI2",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "g",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "ko2nC",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "v6igPI",
+ "name": "t",
+ "fill": "$ink",
+ "content": "ra",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "oZm7p",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "p",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "jX00i",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "MJaf4",
+ "name": "t",
+ "fill": "$ink",
+ "content": "h",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "cS72P",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "y",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "oDvkM",
+ "name": "Typefaces Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "ILTt8",
+ "name": "Section Label",
+ "fill": "$primary-600",
+ "content": "TYPEFACES",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "UVyty",
+ "name": "Specimen Row",
+ "width": "fill_container",
+ "gap": 24,
+ "children": [
+ {
+ "type": "frame",
+ "id": "sHFcr",
+ "name": "Oswald Card",
+ "width": "fill_container",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "gap": 22,
+ "padding": 28,
+ "children": [
+ {
+ "type": "frame",
+ "id": "BeDmf",
+ "name": "Aa Panel",
+ "width": "fill_container",
+ "height": 200,
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-md",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "dwXRl",
+ "name": "Aa",
+ "fill": "$ink",
+ "content": "Aa",
+ "fontFamily": "$font-display",
+ "fontSize": 120,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "A4t4tZ",
+ "name": "Info",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 14,
+ "children": [
+ {
+ "type": "frame",
+ "id": "oBYWc",
+ "name": "Name Row",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "jLlgg",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Oswald",
+ "fontFamily": "$font-display",
+ "fontSize": 30,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "p8Jep",
+ "name": "Tag",
+ "fill": "$primary-600",
+ "content": "DISPLAY / MONO",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "x7081",
+ "name": "Role",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The display & label typeface. Headings, the wordmark, and uppercase letter-spaced UI labels.",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "rectangle",
+ "id": "W8nOu1",
+ "name": "Divider",
+ "fill": "$border",
+ "width": "fill_container",
+ "height": 1
+ },
+ {
+ "type": "text",
+ "id": "n36Zd",
+ "name": "Alphabet",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz 0123456789",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-display",
+ "fontSize": 16,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "sOWuB",
+ "name": "Weights",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "z2N3bS",
+ "name": "Weights Label",
+ "fill": "$text-muted",
+ "content": "WEIGHTS",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "frame",
+ "id": "s0AKjr",
+ "name": "Regular Row",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "JBPd6",
+ "name": "WName",
+ "fill": "$ink",
+ "content": "Regular",
+ "fontFamily": "$font-display",
+ "fontSize": 20
+ },
+ {
+ "type": "text",
+ "id": "cOtNQ",
+ "name": "WVal",
+ "fill": "$text-muted",
+ "content": "400",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "R8Pvfi",
+ "name": "Medium Row",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "f5GJdT",
+ "name": "WName",
+ "fill": "$ink",
+ "content": "Medium",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "npc6S",
+ "name": "WVal",
+ "fill": "$text-muted",
+ "content": "500",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "P2zZvV",
+ "name": "Inter Card",
+ "width": "fill_container",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "gap": 22,
+ "padding": 28,
+ "children": [
+ {
+ "type": "frame",
+ "id": "ilokk",
+ "name": "Aa Panel",
+ "width": "fill_container",
+ "height": 200,
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-md",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "qIkHX",
+ "name": "Aa",
+ "fill": "$ink",
+ "content": "Aa",
+ "fontFamily": "$font-body",
+ "fontSize": 120
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "T47tA6",
+ "name": "Info",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 14,
+ "children": [
+ {
+ "type": "frame",
+ "id": "tPPzW",
+ "name": "Name Row",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "AqkxR",
+ "name": "Name",
+ "fill": "$ink",
+ "content": "Inter",
+ "fontFamily": "$font-body",
+ "fontSize": 30,
+ "fontWeight": "600"
+ },
+ {
+ "type": "text",
+ "id": "CMSJV",
+ "name": "Tag",
+ "fill": "$primary-600",
+ "content": "BODY / SANS",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "gY7CX",
+ "name": "Role",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The body & interface typeface. Paragraphs, descriptions, and most interface text.",
+ "lineHeight": 1.45,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "rectangle",
+ "id": "Rxemm",
+ "name": "Divider",
+ "fill": "$border",
+ "width": "fill_container",
+ "height": 1
+ },
+ {
+ "type": "text",
+ "id": "A3DWc4",
+ "name": "Alphabet",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz 0123456789",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 16,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "XJVaj",
+ "name": "Sample",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "HqE9u",
+ "name": "Sample Label",
+ "fill": "$text-muted",
+ "content": "SAMPLE",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "UmemL",
+ "name": "Paragraph",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Communities improve when people document the problems around them and share what actually works. Inter keeps long-form text legible at every size across the interface.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "S0EZMg",
+ "name": "Type Scale Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "q2IVB",
+ "name": "Section Label",
+ "fill": "$primary-600",
+ "content": "TYPE SCALE",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "OGzxO",
+ "name": "Scale List",
+ "width": "fill_container",
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "frame",
+ "id": "o5Rvyg",
+ "name": "Display Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "g0CAH",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-display",
+ "fontSize": 48,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "tVPn6",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "jdWoI",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "DISPLAY",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "X2DaCQ",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "48px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "tttCe",
+ "name": "Page Title Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "bL9iJ",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-display",
+ "fontSize": 36,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "aVxK7",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "P4fi0",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "PAGE TITLE",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "aK28l",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "36px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "BRLer",
+ "name": "Section Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "EHKsM",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-display",
+ "fontSize": 30,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "WDOqJ",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "IsFeP",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "SECTION",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "g7zXA",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "30px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "wlLGE",
+ "name": "Card Title Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "AR4uJ",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "Lki2r",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "kWX60",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "CARD TITLE",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "ATbNL",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "20px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "H1xVN6",
+ "name": "Body Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "Z2CfiW",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ },
+ {
+ "type": "frame",
+ "id": "IR5jw",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "f1lDQs",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "BODY",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "yx9et",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "16px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "K6BHZ",
+ "name": "Small Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "MrqH8",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ },
+ {
+ "type": "frame",
+ "id": "OXHLI",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "RYhqX",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "SMALL",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "kkGAU",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "14px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "SP4iJ",
+ "name": "Micro Row",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "gap": 32,
+ "padding": [
+ 18,
+ 0
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "VwXmD",
+ "name": "Sample",
+ "fill": "$ink",
+ "content": "Fix what matters",
+ "fontFamily": "$font-body",
+ "fontSize": 10
+ },
+ {
+ "type": "frame",
+ "id": "IIxnZ",
+ "name": "Meta",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "end",
+ "children": [
+ {
+ "type": "text",
+ "id": "vrrUF",
+ "name": "Meta Name",
+ "fill": "$text-muted",
+ "content": "MICRO",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "text",
+ "id": "KPgTC",
+ "name": "Meta Size",
+ "fill": "$text-muted",
+ "content": "10px",
+ "fontFamily": "$font-body",
+ "fontSize": 12,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "sNvCk",
+ "name": "UI Labels Section",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "gBzg2",
+ "name": "Section Label",
+ "fill": "$primary-600",
+ "content": "UI LABELS",
+ "fontFamily": "$font-display",
+ "fontSize": 13,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "OzlFk",
+ "name": "Label Callout",
+ "width": "fill_container",
+ "fill": "$primary-50",
+ "cornerRadius": "$radius-lg",
+ "gap": 48,
+ "padding": 36,
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "U967E",
+ "name": "Explainer",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 12,
+ "children": [
+ {
+ "type": "text",
+ "id": "S91m2B",
+ "name": "Title",
+ "fill": "$ink",
+ "content": "Form label convention",
+ "fontFamily": "$font-display",
+ "fontSize": 24,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "K6TkZ",
+ "name": "Desc",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Field and section labels are set in Oswald, uppercased, at ~11px with ~0.06em letter-spacing and weight 500. This condensed, tracked-out treatment separates labels from Inter body text without relying on color.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ },
+ {
+ "type": "frame",
+ "id": "o0u01Q",
+ "name": "Specs",
+ "gap": 8,
+ "children": [
+ {
+ "type": "frame",
+ "id": "WB4zG",
+ "name": "Chip OSWALD",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 6,
+ 11
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "LXPlq",
+ "name": "Chip Text",
+ "fill": "$text-secondary",
+ "content": "OSWALD",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.5
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "s7zpnJ",
+ "name": "Chip UPPERCASE",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 6,
+ 11
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "r6Yau",
+ "name": "Chip Text",
+ "fill": "$text-secondary",
+ "content": "UPPERCASE",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.5
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "y5xAP",
+ "name": "Chip 11PX",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 6,
+ 11
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "my9Sf",
+ "name": "Chip Text",
+ "fill": "$text-secondary",
+ "content": "11PX",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.5
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "RcgTo",
+ "name": "Chip 0.06EM",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 6,
+ 11
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "zVUiT",
+ "name": "Chip Text",
+ "fill": "$text-secondary",
+ "content": "0.06EM",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.5
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "GlU0P",
+ "name": "Chip WEIGHT 500",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 6,
+ 11
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "SdfiE",
+ "name": "Chip Text",
+ "fill": "$text-secondary",
+ "content": "WEIGHT 500",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.5
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "k8ndwp",
+ "name": "Field Example",
+ "width": 320,
+ "layout": "vertical",
+ "gap": 9,
+ "children": [
+ {
+ "type": "text",
+ "id": "E5BrBA",
+ "name": "Field Label",
+ "fill": "$text-secondary",
+ "content": "EMAIL ADDRESS",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 0.7
+ },
+ {
+ "type": "frame",
+ "id": "gF6vG",
+ "name": "Input",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "strokeAlignment": "inner",
+ "padding": [
+ 13,
+ 15
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "or7WU",
+ "name": "Placeholder",
+ "fill": "$text-muted",
+ "content": "you@example.com",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "normal"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "YtHOT",
+ "name": "Caption",
+ "fill": "$text-muted",
+ "content": "Oswald · 11px · 0.06em · 500",
+ "fontFamily": "$font-body",
+ "fontSize": 11,
+ "fontWeight": "normal"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "fX0IO",
+ "name": "05 Icons & Components",
+ "clip": true,
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 48,
+ "padding": 72,
+ "children": [
+ {
+ "type": "frame",
+ "id": "vP4lK",
+ "name": "Header",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "affqg",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "INTERFACE",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ },
+ {
+ "type": "frame",
+ "id": "PXGV0",
+ "name": "Title",
+ "children": [
+ {
+ "type": "frame",
+ "id": "jpAh2",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "AWg4G",
+ "name": "t",
+ "fill": "$ink",
+ "content": "Icons & Com",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "KEy5d",
+ "name": "gap",
+ "fill": "$ink",
+ "content": "p",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "cEq7x",
+ "name": "seg",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "Cxlm0",
+ "name": "t",
+ "fill": "$ink",
+ "content": "onents",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "MPLky",
+ "name": "Iconography",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "XaWMx",
+ "name": "Sub Label",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 6,
+ "children": [
+ {
+ "type": "text",
+ "id": "GrGHK",
+ "name": "Kicker",
+ "fill": "$neutral-500",
+ "content": "ICONOGRAPHY",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "rTHGI",
+ "name": "Desc",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Icons come from Lucide via Nuxt Icon. Use the lucide set for all UI and utility icons, fa6-brands for social marks, and simple-icons for third-party logos. Keep one consistent line style and size.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 15
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "RXTpK",
+ "name": "Collections",
+ "gap": 10,
+ "children": [
+ {
+ "type": "frame",
+ "id": "LbTkA",
+ "name": "lucide",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "gap": 8,
+ "padding": [
+ 8,
+ 14
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "byx2v",
+ "name": "T",
+ "fill": "$primary-700",
+ "content": "lucide",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "AqoXg",
+ "name": "fa6-brands",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "gap": 8,
+ "padding": [
+ 8,
+ 14
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "l6AHM",
+ "name": "T",
+ "fill": "$primary-700",
+ "content": "fa6-brands",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Ya57u",
+ "name": "simple-icons",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "gap": 8,
+ "padding": [
+ 8,
+ 14
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "CJ1J7",
+ "name": "T",
+ "fill": "$primary-700",
+ "content": "simple-icons",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "DgnkV",
+ "name": "Icon Grid",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 16,
+ "children": [
+ {
+ "type": "frame",
+ "id": "ragXK",
+ "name": "Row",
+ "width": "fill_container",
+ "gap": 16,
+ "children": [
+ {
+ "type": "frame",
+ "id": "f22j6",
+ "name": "plus",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "jAfeb",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "plus",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "PXO4G",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "plus",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "P2ISVj",
+ "name": "map-pin",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "O7p0Wl",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "map-pin",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "bIWEb",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "map-pin",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "fCnUe",
+ "name": "lightbulb",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "U4adO",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "lightbulb",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "f2OZm",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "lightbulb",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "WYJK0",
+ "name": "hand-heart",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "L8L8v",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "hand-heart",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "o9Lzz",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "hand-heart",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "EP905",
+ "name": "users",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "d403y",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "users",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "wAMCM",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "users",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "W7xfz",
+ "name": "shield-check",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "efEuk",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "shield-check",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "xrZXo",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "shield-check",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "nSrt3",
+ "name": "Row",
+ "width": "fill_container",
+ "gap": 16,
+ "children": [
+ {
+ "type": "frame",
+ "id": "f4KbiW",
+ "name": "arrow-up",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "y32sC",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "arrow-up",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "bGfjW",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "arrow-up",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "z6Pl2I",
+ "name": "trending-up",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "vTGzX",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "trending-up",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "e9fhH",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "trending-up",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "QbZzG",
+ "name": "git-fork",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "mmx2E",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "git-fork",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "l0OmL",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "git-fork",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "gkHkQ",
+ "name": "tags",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "oAL9P",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "tags",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "hBNWg",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "tags",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "whRjT",
+ "name": "globe",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "HHWiQ",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "globe",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "jn9yR",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "globe",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "AajOV",
+ "name": "badge-check",
+ "width": "fill_container",
+ "fill": "$surface-subtle",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 12,
+ "padding": [
+ 22,
+ 12
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "Iy6dI",
+ "name": "Icon",
+ "width": 26,
+ "height": 26,
+ "icon": "badge-check",
+ "library": "lucide",
+ "fill": "$neutral-800"
+ },
+ {
+ "type": "text",
+ "id": "u15eE",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "badge-check",
+ "fontFamily": "$font-body",
+ "fontSize": 11
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "NUl5y",
+ "name": "Social",
+ "fill": "$neutral-950",
+ "cornerRadius": "$radius-lg",
+ "gap": 14,
+ "padding": [
+ 16,
+ 18
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "uwft9",
+ "name": "Lbl",
+ "fill": "$neutral-500",
+ "content": "BRAND / SOCIAL",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "Q6ckM",
+ "name": "Sp",
+ "width": 8,
+ "height": 1
+ },
+ {
+ "type": "frame",
+ "id": "U6L23b",
+ "name": "GitHub Item",
+ "gap": 7,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "path",
+ "id": "qkP2S",
+ "name": "GitHub Icon",
+ "geometry": "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 0.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-0.3 5.6 1.3 5.6 3.6m-31.1-4.5c-0.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-0.7-5.5 0.3-6.2 2.3m44.2-1.7c-2.9 0.7-4.9 2.6-4.6 4.9 0.3 2 2.9 3.3 5.9 2.6 2.9-0.7 4.9-2.6 4.6-4.6-0.3-1.9-3-3.2-5.9-2.9m65.8-383.2c-138.7 0-244.8 105.3-244.8 244 0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-0.3-40.4-0.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-0.3 75.4-0.3 83.6 0 6.5 4.6 14.4 17.3 12.1 100-33.2 167.8-128.1 167.8-239 0-138.7-112.5-244-251.2-244m-147.6 344.9c-1.3 1-1 3.3 0.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-0.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-0.7 1.3 0.3 2.9 2.3 3.9 1.6 1 3.6 0.7 4.3-0.7 0.7-1.3-0.3-2.9-2.3-3.9-2-0.6-3.6-0.3-4.3 0.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 0.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2",
+ "viewBox": [
+ 0,
+ 0,
+ 496,
+ 512
+ ],
+ "fill": "$neutral-300",
+ "width": 14.5,
+ "height": 15
+ },
+ {
+ "type": "text",
+ "id": "oS3Ij",
+ "name": "GitHub",
+ "fill": "$neutral-300",
+ "content": "GitHub",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "WrzKC",
+ "name": "Bluesky Item",
+ "gap": 7,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "path",
+ "id": "sWqAW",
+ "name": "Bluesky Icon",
+ "geometry": "M111.8 62.2c58.4 43.7 121.2 132.5 144.2 180.2 23-47.6 85.8-136.4 144.2-180.2 42.1-31.6 110.3-56 110.3 21.8 0 15.5-8.9 130.5-14.1 149.2-18.2 64.8-84.4 81.4-143.3 71.3 102.9 17.5 129.1 75.5 72.5 133.5-107.4 110.2-154.3-27.6-166.3-62.9-1.7-4.9-2.6-7.8-3.3-7.8s-1.6 3-3.3 7.8c-12 35.3-59 173.1-166.3 62.9-56.5-58-30.4-116 72.5-133.5-58.9 10.1-125.1-6.5-143.2-71.4-5.3-18.7-14.2-133.7-14.2-149.2 0-77.8 68.2-53.4 110.3-21.8z",
+ "viewBox": [
+ 0,
+ 0,
+ 512,
+ 512
+ ],
+ "fill": "$neutral-300",
+ "width": 15,
+ "height": 15
+ },
+ {
+ "type": "text",
+ "id": "H6XJlu",
+ "name": "Bluesky",
+ "fill": "$neutral-300",
+ "content": "Bluesky",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "jXvVJ",
+ "name": "Google Item",
+ "gap": 7,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "path",
+ "id": "TdBfe",
+ "name": "Google Icon",
+ "geometry": "M488 261.8c0 141.5-96.9 242.2-240 242.2-137.2 0-248-110.8-248-248s110.8-248 248-248c66.8 0 123 24.5 166.3 64.9l-67.5 64.9c-88.3-85.2-252.5-21.2-252.5 118.2 0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9h-140.8v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4",
+ "viewBox": [
+ 0,
+ 0,
+ 488,
+ 512
+ ],
+ "fill": "$neutral-300",
+ "width": 14.3,
+ "height": 15
+ },
+ {
+ "type": "text",
+ "id": "Q2Pzr",
+ "name": "Google",
+ "fill": "$neutral-300",
+ "content": "Google",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "gIxhQ",
+ "name": "Apple Item",
+ "gap": 7,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "path",
+ "id": "j1WFs",
+ "name": "Apple Icon",
+ "geometry": "M318.7 268.7c-0.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7-55.8 0.9-115.1 44.5-115.1 133.2q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-0.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-0.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9m-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3",
+ "viewBox": [
+ 0,
+ 0,
+ 384,
+ 512
+ ],
+ "fill": "$neutral-300",
+ "width": 11.3,
+ "height": 15
+ },
+ {
+ "type": "text",
+ "id": "c3I5yc",
+ "name": "Apple",
+ "fill": "$neutral-300",
+ "content": "Apple",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "fiBdJ",
+ "name": "Note",
+ "fill": "$neutral-600",
+ "content": "· via fa6-brands & simple-icons",
+ "fontFamily": "$font-body",
+ "fontSize": 13
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "S811B",
+ "name": "Components",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 22,
+ "children": [
+ {
+ "type": "frame",
+ "id": "NK0x2",
+ "name": "Sub Label",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 6,
+ "children": [
+ {
+ "type": "text",
+ "id": "XitEm",
+ "name": "Kicker",
+ "fill": "$neutral-500",
+ "content": "COMPONENTS",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "Y8TLtw",
+ "name": "Desc",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Built on Nuxt UI — rounded, restrained, accessible. Blue drives primary actions; neutrals carry everything else.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 15
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "x0Wr9",
+ "name": "Buttons",
+ "gap": 14,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "UZJdd",
+ "name": "Primary",
+ "fill": "$primary-600",
+ "cornerRadius": "$radius-md",
+ "gap": 8,
+ "padding": [
+ 12,
+ 20
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "nZ7Zj",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "plus",
+ "library": "lucide",
+ "fill": "#ffffff"
+ },
+ {
+ "type": "text",
+ "id": "j4E51e",
+ "name": "t",
+ "fill": "#ffffff",
+ "content": "Submit issue",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "G0wQ8",
+ "name": "Outline",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$neutral-300",
+ "strokeWidth": 1,
+ "gap": 8,
+ "padding": [
+ 12,
+ 20
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "y9oaA5",
+ "name": "t",
+ "fill": "$neutral-800",
+ "content": "Cancel",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "DVg2V",
+ "name": "Ghost",
+ "gap": 8,
+ "padding": [
+ 12,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "L3I99M",
+ "name": "t",
+ "fill": "$primary-600",
+ "content": "Learn more",
+ "fontFamily": "$font-body",
+ "fontSize": 15,
+ "fontWeight": "600"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "vfjtb",
+ "name": "Badges",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "PR8Hf",
+ "name": "Pending",
+ "fill": "$neutral-100",
+ "cornerRadius": 999,
+ "gap": 6,
+ "padding": [
+ 6,
+ 12
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "ellipse",
+ "id": "hKoaa",
+ "name": "dot",
+ "fill": "$neutral-400",
+ "width": 8,
+ "height": 8
+ },
+ {
+ "type": "text",
+ "id": "exbxa",
+ "name": "t",
+ "fill": "$neutral-600",
+ "content": "Pending",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "UaGpa",
+ "name": "Approved",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "gap": 6,
+ "padding": [
+ 6,
+ 12
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "jUqqZ",
+ "name": "i",
+ "width": 13,
+ "height": 13,
+ "icon": "badge-check",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "kTiPH",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Approved",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "k3n6X",
+ "name": "#clean-water",
+ "fill": "$neutral-100",
+ "cornerRadius": 999,
+ "padding": [
+ 6,
+ 12
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "Op7VJ",
+ "name": "t",
+ "fill": "$neutral-700",
+ "content": "#clean-water",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "kR0fl",
+ "name": "#open-data",
+ "fill": "$neutral-100",
+ "cornerRadius": 999,
+ "padding": [
+ 6,
+ 12
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "HuPVE",
+ "name": "t",
+ "fill": "$neutral-700",
+ "content": "#open-data",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "NaXpl",
+ "name": "Card + Field",
+ "width": "fill_container",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "f7y4T",
+ "name": "Issue Card",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": 16,
+ "stroke": "$border",
+ "strokeWidth": 1,
+ "effect": {
+ "type": "shadow",
+ "shadowType": "outer",
+ "color": "#0a0a0a1a",
+ "offset": {
+ "x": 0,
+ "y": 10
+ },
+ "blur": 28,
+ "spread": -8
+ },
+ "layout": "vertical",
+ "gap": 12,
+ "padding": 22,
+ "children": [
+ {
+ "type": "frame",
+ "id": "izSag",
+ "name": "Top",
+ "width": "fill_container",
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "jYQs8",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "ISSUE",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "GUnBE",
+ "name": "Upvotes",
+ "gap": 5,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "j2zis",
+ "name": "i",
+ "width": 14,
+ "height": 14,
+ "icon": "arrow-up",
+ "library": "lucide",
+ "fill": "$neutral-500"
+ },
+ {
+ "type": "text",
+ "id": "TjYpL",
+ "name": "t",
+ "fill": "$neutral-600",
+ "content": "128",
+ "fontFamily": "$font-body",
+ "fontSize": 13,
+ "fontWeight": "600"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "DRI13",
+ "name": "CardTitle",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Restore the riverside footpath",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "XqgtL",
+ "name": "CardBody",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The path along the lower bank floods every spring. A few volunteers and reclaimed gravel could keep it open year-round.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ },
+ {
+ "type": "frame",
+ "id": "vEtSZ",
+ "name": "Meta",
+ "gap": 6,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "W9AUL",
+ "name": "i",
+ "width": 14,
+ "height": 14,
+ "icon": "map-pin",
+ "library": "lucide",
+ "fill": "$neutral-400"
+ },
+ {
+ "type": "text",
+ "id": "W3isj",
+ "name": "t",
+ "fill": "$neutral-500",
+ "content": "Lyon, France",
+ "fontFamily": "$font-body",
+ "fontSize": 13
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "zSDmU",
+ "name": "Field",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "text",
+ "id": "Ce19a",
+ "name": "Label",
+ "fill": "$neutral-500",
+ "content": "EMAIL ADDRESS",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ },
+ {
+ "type": "frame",
+ "id": "cA5PW",
+ "name": "Input",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$neutral-300",
+ "strokeWidth": 1,
+ "padding": [
+ 13,
+ 14
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "eDZda",
+ "name": "ph",
+ "fill": "$neutral-400",
+ "content": "you@example.com",
+ "fontFamily": "$font-body",
+ "fontSize": 15
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "i4dkcQ",
+ "name": "Help",
+ "fill": "$text-muted",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Inputs use a neutral border, soft radius, and the uppercase Oswald label above.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 13
+ },
+ {
+ "type": "frame",
+ "id": "fDRZg",
+ "name": "Focus",
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-md",
+ "stroke": "$primary-500",
+ "strokeWidth": 2,
+ "padding": [
+ 13,
+ 14
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "yca8Z",
+ "name": "ph",
+ "fill": "$neutral-800",
+ "content": "Focused — blue ring",
+ "fontFamily": "$font-body",
+ "fontSize": 15
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "y5GLs",
+ "name": "06 Voice & Tone",
+ "clip": true,
+ "width": "fill_container",
+ "fill": "$surface",
+ "cornerRadius": "$radius-board",
+ "layout": "vertical",
+ "gap": 48,
+ "padding": 72,
+ "children": [
+ {
+ "type": "frame",
+ "id": "vfdpS",
+ "name": "Header",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "zL4zD",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "VOICE",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "500",
+ "letterSpacing": 3
+ },
+ {
+ "type": "frame",
+ "id": "b98tA",
+ "name": "Title",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "padding": [
+ 0,
+ 0,
+ 5,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "cI6xC",
+ "name": "Title Text",
+ "fill": "$ink",
+ "content": "Voice & Tone",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "F3A8h0",
+ "name": "Personality",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 16,
+ "children": [
+ {
+ "type": "text",
+ "id": "c7VlmF",
+ "name": "Kicker",
+ "fill": "$neutral-500",
+ "content": "HOW WE SOUND",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "text",
+ "id": "bBPu0",
+ "name": "Desc",
+ "fill": "$text-secondary",
+ "textGrowth": "fixed-width",
+ "width": 840,
+ "content": "We speak as a community organizer, not a corporation. Warm, plain-spoken, and oriented toward doing. We invite people in and put their skills to work — never lecture, never over-promise.",
+ "lineHeight": 1.6,
+ "fontFamily": "$font-body",
+ "fontSize": 17
+ },
+ {
+ "type": "frame",
+ "id": "WEUXX",
+ "name": "Traits",
+ "gap": 10,
+ "children": [
+ {
+ "type": "frame",
+ "id": "bv1l0",
+ "name": "Empowering",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "padding": [
+ 8,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "SgPhb",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Empowering",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "J41JC",
+ "name": "Inclusive",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "padding": [
+ 8,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "NGWfR",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Inclusive",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "O1Mygh",
+ "name": "Action-oriented",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "padding": [
+ 8,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "P3l2b",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Action-oriented",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "wNM9h",
+ "name": "Collaborative",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "padding": [
+ 8,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "o0GI1",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Collaborative",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "g3rnnM",
+ "name": "Impact-focused",
+ "fill": "$primary-50",
+ "cornerRadius": 999,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "padding": [
+ 8,
+ 16
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "tQJhd",
+ "name": "t",
+ "fill": "$primary-700",
+ "content": "Impact-focused",
+ "fontFamily": "$font-body",
+ "fontSize": 14,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "B1NTp",
+ "name": "Pull Quote",
+ "width": "fill_container",
+ "gap": 24,
+ "padding": [
+ 10,
+ 0
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "P7p2fc",
+ "name": "Quote Block",
+ "width": "fill_container",
+ "stroke": "$primary-500",
+ "strokeWidth": {
+ "left": 5
+ },
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "gap": 10,
+ "padding": [
+ 4,
+ 0,
+ 4,
+ 24
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "JRAco",
+ "name": "Quote",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Let's put our skills to work.",
+ "lineHeight": 1.15,
+ "fontFamily": "$font-body",
+ "fontSize": 40,
+ "fontWeight": "600"
+ },
+ {
+ "type": "text",
+ "id": "TAyeG",
+ "name": "Attr",
+ "fill": "$text-secondary",
+ "content": "Our tagline — a direct, inclusive invitation to contribute, not a slogan to admire.",
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "R9gdqN",
+ "name": "Messaging",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "XYb1B",
+ "name": "Kicker",
+ "fill": "$neutral-500",
+ "content": "MESSAGING IN CONTEXT",
+ "fontFamily": "$font-display",
+ "fontSize": 12,
+ "fontWeight": "500",
+ "letterSpacing": 2
+ },
+ {
+ "type": "frame",
+ "id": "wvDen",
+ "name": "List",
+ "width": "fill_container",
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "frame",
+ "id": "CRc3l",
+ "name": "HOMEPAGE",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "strokeAlignment": "inner",
+ "gap": 28,
+ "padding": [
+ 15,
+ 0
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "sVtyQ",
+ "name": "Ctx",
+ "width": 200,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "KOHRh",
+ "name": "l",
+ "fill": "$neutral-500",
+ "content": "HOMEPAGE",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "yl54k",
+ "name": "copy",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Let's put our skills to work.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "NxYqK",
+ "name": "ISSUES FEED",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "strokeAlignment": "inner",
+ "gap": 28,
+ "padding": [
+ 15,
+ 0
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "vVL3Y",
+ "name": "Ctx",
+ "width": 200,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "xCEqg",
+ "name": "l",
+ "fill": "$neutral-500",
+ "content": "ISSUES FEED",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "doGa3",
+ "name": "copy",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The community is actively working on these issues:",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "gPvJj",
+ "name": "SEO / META",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "strokeAlignment": "inner",
+ "gap": 28,
+ "padding": [
+ 15,
+ 0
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "x1EOz",
+ "name": "Ctx",
+ "width": 200,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "b2x3Rr",
+ "name": "l",
+ "fill": "$neutral-500",
+ "content": "SEO / META",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "FuRma",
+ "name": "copy",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Find and co-create solutions to local and global issues. Share skills, back ideas, collaborate on impact.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Hrnm6",
+ "name": "POSITIONING",
+ "width": "fill_container",
+ "stroke": "$border",
+ "strokeWidth": {
+ "bottom": 1
+ },
+ "strokeAlignment": "inner",
+ "gap": 28,
+ "padding": [
+ 15,
+ 0
+ ],
+ "children": [
+ {
+ "type": "frame",
+ "id": "dm35U",
+ "name": "Ctx",
+ "width": 200,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "Wy1Cy",
+ "name": "l",
+ "fill": "$neutral-500",
+ "content": "POSITIONING",
+ "fontFamily": "$font-display",
+ "fontSize": 11,
+ "fontWeight": "500",
+ "letterSpacing": 1.5
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "Cc4j8",
+ "name": "copy",
+ "fill": "$text-primary",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The community-driven solutions platform.",
+ "lineHeight": 1.5,
+ "fontFamily": "$font-body",
+ "fontSize": 16
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "wltQV",
+ "name": "Do & Dont",
+ "width": "fill_container",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "v73kQ",
+ "name": "DO",
+ "width": "fill_container",
+ "fill": "#f0fdf4",
+ "cornerRadius": 16,
+ "stroke": "#bbf7d0",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 16,
+ "padding": 26,
+ "children": [
+ {
+ "type": "frame",
+ "id": "h0YX5k",
+ "name": "Head",
+ "gap": 10,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "Ks8pY",
+ "name": "i",
+ "width": 20,
+ "height": 20,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "#16a34a"
+ },
+ {
+ "type": "text",
+ "id": "q0tMkO",
+ "name": "t",
+ "fill": "$ink",
+ "content": "DO",
+ "fontFamily": "$font-display",
+ "fontSize": 18,
+ "fontWeight": "500",
+ "letterSpacing": 1
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "vwuUE",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "x2512",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "#16a34a"
+ },
+ {
+ "type": "text",
+ "id": "izqnj",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Lead with action — \"tackle\", \"co-create\", \"back an idea\".",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "itKFM",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "HRYle",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "#16a34a"
+ },
+ {
+ "type": "text",
+ "id": "H7hDI",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Speak to everyone; assume no special expertise.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "nVOLP",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "FrCuq",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "#16a34a"
+ },
+ {
+ "type": "text",
+ "id": "B7GHge",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Make impact concrete and local before global.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "P0yDD",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "awRN1",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "check",
+ "library": "lucide",
+ "fill": "#16a34a"
+ },
+ {
+ "type": "text",
+ "id": "XvfxU",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Credit the community, not the platform.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Po3de",
+ "name": "DON'T",
+ "width": "fill_container",
+ "fill": "#fef2f2",
+ "cornerRadius": 16,
+ "stroke": "#fecaca",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "gap": 16,
+ "padding": 26,
+ "children": [
+ {
+ "type": "frame",
+ "id": "YQSus",
+ "name": "Head",
+ "gap": 10,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "KHLbE",
+ "name": "i",
+ "width": 20,
+ "height": 20,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "#dc2626"
+ },
+ {
+ "type": "text",
+ "id": "X5mer",
+ "name": "t",
+ "fill": "$ink",
+ "content": "DON'T",
+ "fontFamily": "$font-display",
+ "fontSize": 18,
+ "fontWeight": "500",
+ "letterSpacing": 1
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "whzPH",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "fvnru",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "#dc2626"
+ },
+ {
+ "type": "text",
+ "id": "BRUqZ",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Lean on corporate jargon or empty buzzwords.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "bnCdM",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "Yx7pO",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "#dc2626"
+ },
+ {
+ "type": "text",
+ "id": "N8emod",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Talk down or gatekeep with insider language.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "eYyzz",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "HKHwZ",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "#dc2626"
+ },
+ {
+ "type": "text",
+ "id": "Asvfl",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Over-promise outcomes or hype results.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "vHaOY",
+ "name": "Item",
+ "width": "fill_container",
+ "gap": 10,
+ "children": [
+ {
+ "type": "icon",
+ "id": "r06BPh",
+ "name": "i",
+ "width": 16,
+ "height": 16,
+ "icon": "x",
+ "library": "lucide",
+ "fill": "#dc2626"
+ },
+ {
+ "type": "text",
+ "id": "wlzOr",
+ "name": "t",
+ "fill": "$neutral-700",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Center the product over the people.",
+ "lineHeight": 1.55,
+ "fontFamily": "$font-body",
+ "fontSize": 14
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "chxkP",
+ "x": 0,
+ "y": 8817.615856573706,
+ "name": "CommunityFix — IG Carousel",
+ "fill": "$neutral-300",
+ "gap": 64,
+ "padding": 64,
+ "children": [
+ {
+ "type": "frame",
+ "id": "RAFau",
+ "name": "01 Hero",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$primary-600",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "sxKF2",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "k7I3Qd",
+ "name": "Wordmark",
+ "fill": "#ffffff",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "UFe6z",
+ "name": "Body 01",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 34,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "gdUsi",
+ "name": "Node Motif",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "ellipse",
+ "id": "WU6yB",
+ "name": "Dot L",
+ "fill": "#FFFFFFB3",
+ "width": 12,
+ "height": 12
+ },
+ {
+ "type": "rectangle",
+ "id": "Wh8o6",
+ "name": "Link L",
+ "fill": "#FFFFFF4D",
+ "width": 54,
+ "height": 2
+ },
+ {
+ "type": "ellipse",
+ "id": "iBbvi",
+ "name": "Hub",
+ "fill": "#FFFFFF",
+ "width": 18,
+ "height": 18
+ },
+ {
+ "type": "rectangle",
+ "id": "NdMa9",
+ "name": "Link R",
+ "fill": "#FFFFFF4D",
+ "width": 54,
+ "height": 2
+ },
+ {
+ "type": "ellipse",
+ "id": "ajmGD",
+ "name": "Dot R",
+ "fill": "#FFFFFFB3",
+ "width": 12,
+ "height": 12
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "fBgfP",
+ "name": "Eyebrow",
+ "fill": "$primary-200",
+ "content": "WHAT IS COMMUNITYFIX?",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "600",
+ "letterSpacing": 4
+ },
+ {
+ "type": "frame",
+ "id": "EY36Z",
+ "name": "Headline",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "ygU6K",
+ "name": "Lead",
+ "fill": "#FFFFFFF2",
+ "content": "We turn local fixes into",
+ "lineHeight": 1.04,
+ "fontFamily": "$font-display",
+ "fontSize": 54,
+ "fontWeight": "600"
+ },
+ {
+ "type": "text",
+ "id": "mTrP6",
+ "name": "Punch",
+ "fill": "#FFFFFF",
+ "content": "shared knowledge.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 104,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "nc2Dq",
+ "name": "Support",
+ "fill": "#FFFFFFCC",
+ "content": "The open library of what actually works.",
+ "fontFamily": "$font-body",
+ "fontSize": 23
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "iWAQJ",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "TNV1b",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "#ffffff"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "UtMbg",
+ "name": "02 Problem",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$neutral-100",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "S7w0Hv",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "ibent",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Q0iz3",
+ "name": "Body 02",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 40,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "zbud2",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "THE PROBLEM",
+ "fontFamily": "$font-display",
+ "fontSize": 17,
+ "fontWeight": "600",
+ "letterSpacing": 4
+ },
+ {
+ "type": "frame",
+ "id": "m5aTH",
+ "name": "Headline",
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "aYhTs",
+ "name": "Line 1",
+ "fill": "$ink",
+ "content": "Most good ideas",
+ "lineHeight": 1.02,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "umHXx",
+ "name": "Line 2",
+ "fill": "$ink",
+ "content": "die local.",
+ "lineHeight": 1.02,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "gRLef",
+ "name": "Staccato",
+ "layout": "vertical",
+ "gap": 16,
+ "children": [
+ {
+ "type": "text",
+ "id": "JkUd6",
+ "name": "S1",
+ "fill": "$text-secondary",
+ "content": "One city solves it.",
+ "fontFamily": "$font-body",
+ "fontSize": 30,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "q9Dvn",
+ "name": "S2",
+ "fill": "$text-secondary",
+ "content": "One report.",
+ "fontFamily": "$font-body",
+ "fontSize": 30,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "K14Kqp",
+ "name": "S3",
+ "fill": "$text-secondary",
+ "content": "Then it's gone —",
+ "fontFamily": "$font-body",
+ "fontSize": 30,
+ "fontWeight": "500"
+ },
+ {
+ "type": "text",
+ "id": "qSlab",
+ "name": "S4",
+ "fill": "$text-secondary",
+ "content": "while everyone else",
+ "fontFamily": "$font-body",
+ "fontSize": 30,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "mEwaT",
+ "name": "Punch Wrap",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "JK02h",
+ "name": "Punch",
+ "fill": "$primary-600",
+ "content": "starts from scratch.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 50,
+ "fontWeight": "700"
+ },
+ {
+ "type": "rectangle",
+ "cornerRadius": 4,
+ "id": "maOzY",
+ "name": "Underline",
+ "fill": "$primary-600",
+ "width": 407,
+ "height": 8
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "rLOVJ",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "N2KRDU",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "yvPnq",
+ "name": "03 Mission",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$surface",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "WZAlQ",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "K3XX9",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "ruxm5",
+ "name": "Body 03",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 46,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "d76x5B",
+ "name": "Mission Head",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 22,
+ "children": [
+ {
+ "type": "text",
+ "id": "rOCvk",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "THE MISSION",
+ "fontFamily": "$font-display",
+ "fontSize": 16,
+ "fontWeight": "600",
+ "letterSpacing": 3
+ },
+ {
+ "type": "text",
+ "id": "KGH4B",
+ "name": "Lead",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "No community should have to solve a problem someone else already cracked.",
+ "lineHeight": 1.04,
+ "fontFamily": "$font-display",
+ "fontSize": 72,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "C78wYV",
+ "name": "Punchline",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "text",
+ "id": "Z7dAm4",
+ "name": "Intro",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "So we're building the map of",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 46,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "BWWya",
+ "name": "Blue Phrase",
+ "stroke": "$primary-600",
+ "strokeWidth": {
+ "bottom": 6
+ },
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "padding": [
+ 0,
+ 0,
+ 10,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "q7Lxt",
+ "name": "Phrase",
+ "fill": "$primary-600",
+ "content": "civic solutions that actually worked.",
+ "lineHeight": 1.05,
+ "fontFamily": "$font-display",
+ "fontSize": 48,
+ "fontWeight": "700"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "p5863",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "klvbd",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "yEsPE",
+ "name": "04 How it works",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$neutral-100",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "wdP8w",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "hHYXI",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "qvp2K",
+ "name": "Body 04",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 52,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "DeApd",
+ "name": "Title",
+ "stroke": "$primary-600",
+ "strokeWidth": {
+ "bottom": 6
+ },
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "padding": [
+ 0,
+ 0,
+ 12,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "kczP3",
+ "name": "Title Text",
+ "fill": "$ink",
+ "content": "How it works",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 66,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "xK2XF",
+ "name": "Steps",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 20,
+ "children": [
+ {
+ "type": "frame",
+ "id": "G3z76z",
+ "name": "Step 1",
+ "width": "fill_container",
+ "fill": "$primary-50",
+ "cornerRadius": "$radius-lg",
+ "gap": 28,
+ "padding": [
+ 28,
+ 34
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "F1Q3dw",
+ "name": "Num",
+ "width": 64,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "sxFAp",
+ "name": "N",
+ "fill": "$primary-600",
+ "content": "1",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 60,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "m97kKp",
+ "name": "Label",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Someone documents a real problem",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 34,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "xcYs1",
+ "name": "Step 2",
+ "width": "fill_container",
+ "fill": "$primary-50",
+ "cornerRadius": "$radius-lg",
+ "gap": 28,
+ "padding": [
+ 28,
+ 34
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "ke0Jq",
+ "name": "Num",
+ "width": 64,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "iASAu",
+ "name": "N",
+ "fill": "$primary-600",
+ "content": "2",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 60,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "NgOGB",
+ "name": "Label",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "The community proposes solutions",
+ "lineHeight": 1.1,
+ "fontFamily": "$font-display",
+ "fontSize": 34,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "VzRHQ",
+ "name": "Step 3",
+ "width": "fill_container",
+ "fill": "$primary-50",
+ "cornerRadius": "$radius-lg",
+ "gap": 28,
+ "padding": [
+ 28,
+ 34
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "Silgn",
+ "name": "Num",
+ "width": 64,
+ "layout": "vertical",
+ "children": [
+ {
+ "type": "text",
+ "id": "cwVtT",
+ "name": "N",
+ "fill": "$primary-600",
+ "content": "3",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 60,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "p1g5U",
+ "name": "Label",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "U5mVz7",
+ "name": "L1",
+ "fill": "$ink",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Case studies prove what worked —",
+ "lineHeight": 1.12,
+ "fontFamily": "$font-display",
+ "fontSize": 34,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "e4Ff5q",
+ "name": "Receipts",
+ "stroke": "$primary-600",
+ "strokeWidth": {
+ "bottom": 4
+ },
+ "strokeAlignment": "inner",
+ "layout": "vertical",
+ "padding": [
+ 0,
+ 0,
+ 6,
+ 0
+ ],
+ "children": [
+ {
+ "type": "text",
+ "id": "TJ4MO",
+ "name": "R",
+ "fill": "$primary-600",
+ "content": "with the receipts",
+ "lineHeight": 1.12,
+ "fontFamily": "$font-display",
+ "fontSize": 34,
+ "fontWeight": "700"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "RkLQC",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "G0IRY",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "H4Zh5",
+ "name": "05 What we stand for",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$surface",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "mDGMg",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "WnQlT",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "o5ZF75",
+ "name": "Body 05",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 56,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "YmrxE",
+ "name": "Heading",
+ "layout": "vertical",
+ "gap": 22,
+ "children": [
+ {
+ "type": "text",
+ "id": "mWX4V",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "WHAT WE STAND FOR",
+ "fontFamily": "$font-display",
+ "fontSize": 15,
+ "fontWeight": "600",
+ "letterSpacing": 4
+ },
+ {
+ "type": "frame",
+ "id": "PtJYX",
+ "name": "Pillars",
+ "layout": "vertical",
+ "gap": 2,
+ "children": [
+ {
+ "type": "text",
+ "id": "nFvfV",
+ "name": "Open-source.",
+ "fill": "$ink",
+ "content": "Open-source.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 92,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "n1iWS",
+ "name": "Non-profit.",
+ "fill": "$ink",
+ "content": "Non-profit.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 92,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "s8dycN",
+ "name": "Steward-owned.",
+ "fill": "$ink",
+ "content": "Steward-owned.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 92,
+ "fontWeight": "700"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "mFocm",
+ "name": "Statement",
+ "layout": "vertical",
+ "gap": 12,
+ "children": [
+ {
+ "type": "text",
+ "id": "f6tsH",
+ "name": "Stmt L1",
+ "fill": "$ink",
+ "content": "The knowledge is a commons —",
+ "lineHeight": 1.12,
+ "fontFamily": "$font-display",
+ "fontSize": 40,
+ "fontWeight": "500"
+ },
+ {
+ "type": "frame",
+ "id": "gu8yi",
+ "name": "Phrase",
+ "layout": "vertical",
+ "gap": 10,
+ "children": [
+ {
+ "type": "text",
+ "id": "JW5A3",
+ "name": "Phrase Text",
+ "fill": "$primary-600",
+ "content": "free, public, forever.",
+ "lineHeight": 1.12,
+ "fontFamily": "$font-display",
+ "fontSize": 40,
+ "fontWeight": "600"
+ },
+ {
+ "type": "rectangle",
+ "cornerRadius": 999,
+ "id": "e6GXV",
+ "name": "Underline",
+ "fill": "$primary-600",
+ "width": 320,
+ "height": 5
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "nNbGp",
+ "name": "Closing",
+ "fill": "$text-secondary",
+ "content": "No shareholders. No paywalls.",
+ "fontFamily": "$font-body",
+ "fontSize": 22,
+ "fontWeight": "500"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "eNZ9K",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "tMPxu",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "b7pqZ",
+ "name": "06 Where this is going",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$neutral-100",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "PhXta",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "SIl2P",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "tCmp5",
+ "name": "Body 06",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 44,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "j109k",
+ "name": "Heading",
+ "layout": "vertical",
+ "gap": 28,
+ "children": [
+ {
+ "type": "frame",
+ "id": "D4hE4Y",
+ "name": "Tag Row",
+ "children": [
+ {
+ "type": "frame",
+ "id": "MVRCu",
+ "name": "SOON Tag",
+ "fill": "$primary-600",
+ "cornerRadius": 999,
+ "padding": [
+ 7,
+ 15
+ ],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "CzUWg",
+ "name": "t",
+ "fill": "#ffffff",
+ "content": "SOON",
+ "fontFamily": "$font-display",
+ "fontSize": 14,
+ "fontWeight": "600",
+ "letterSpacing": 3
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "ZqEBy",
+ "name": "Headline",
+ "layout": "vertical",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "r6ozn",
+ "name": "H L1",
+ "fill": "$ink",
+ "content": "Soon, you won't",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "pdDna",
+ "name": "H L2",
+ "fill": "$ink",
+ "content": "just find fixes.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ },
+ {
+ "type": "frame",
+ "id": "iV3U5",
+ "name": "H L3",
+ "children": [
+ {
+ "type": "text",
+ "id": "F3wD6h",
+ "name": "Youll",
+ "fill": "$ink",
+ "content": "You'll ",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ },
+ {
+ "type": "frame",
+ "id": "V1rh5",
+ "name": "Fund Wrap",
+ "layout": "vertical",
+ "gap": 8,
+ "children": [
+ {
+ "type": "text",
+ "id": "FNGNF",
+ "name": "fund them",
+ "fill": "$primary-600",
+ "content": "fund them.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 78,
+ "fontWeight": "700"
+ },
+ {
+ "type": "rectangle",
+ "cornerRadius": 999,
+ "id": "V8rdN",
+ "name": "Underline",
+ "fill": "$primary-600",
+ "width": 300,
+ "height": 6
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "isWzY",
+ "name": "Bullets",
+ "layout": "vertical",
+ "gap": 18,
+ "children": [
+ {
+ "type": "frame",
+ "id": "WXzoR",
+ "name": "Bullet",
+ "gap": 14,
+ "children": [
+ {
+ "type": "text",
+ "id": "yclm1",
+ "name": "arrow",
+ "fill": "$primary-600",
+ "content": "→",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20,
+ "fontWeight": "600"
+ },
+ {
+ "type": "frame",
+ "id": "Z2p6mf",
+ "name": "Text",
+ "children": [
+ {
+ "type": "text",
+ "id": "JSVgP",
+ "name": "Back any i",
+ "fill": "$ink",
+ "content": "Back any issue, solution, or case study",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "i8xTLY",
+ "name": "Bullet",
+ "gap": 14,
+ "children": [
+ {
+ "type": "text",
+ "id": "zqjQ3",
+ "name": "arrow",
+ "fill": "$primary-600",
+ "content": "→",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20,
+ "fontWeight": "600"
+ },
+ {
+ "type": "frame",
+ "id": "v8737U",
+ "name": "Text",
+ "children": [
+ {
+ "type": "text",
+ "id": "j5QHxG",
+ "name": "Builders t",
+ "fill": "$ink",
+ "content": "Builders turn the best into real companies — ",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20
+ },
+ {
+ "type": "text",
+ "id": "V5m8k",
+ "name": "equity, no",
+ "fill": "$primary-600",
+ "content": "equity, not just credit",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20,
+ "fontWeight": "600"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "DI1mw",
+ "name": "Bullet",
+ "gap": 14,
+ "children": [
+ {
+ "type": "text",
+ "id": "AlEAU",
+ "name": "arrow",
+ "fill": "$primary-600",
+ "content": "→",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20,
+ "fontWeight": "600"
+ },
+ {
+ "type": "frame",
+ "id": "mUR4w",
+ "name": "Text",
+ "children": [
+ {
+ "type": "text",
+ "id": "RIOM3",
+ "name": "Every comp",
+ "fill": "$ink",
+ "content": "Every company sends ",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20
+ },
+ {
+ "type": "text",
+ "id": "gYfPP",
+ "name": "1% back",
+ "fill": "$primary-600",
+ "content": "1% back",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20,
+ "fontWeight": "600"
+ },
+ {
+ "type": "text",
+ "id": "P89qCl",
+ "name": ", to seed ",
+ "fill": "$ink",
+ "content": ", to seed the next fix",
+ "lineHeight": 1.4,
+ "fontFamily": "$font-body",
+ "fontSize": 20
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "EpHBC",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "tgJBi",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "w0QXU",
+ "name": "07 The flywheel",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$surface",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "aat11",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "JUSdC",
+ "name": "Wordmark",
+ "fill": "$ink",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "yfuNS",
+ "name": "Body 07",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 52,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "hQdie",
+ "name": "Header",
+ "layout": "vertical",
+ "gap": 14,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "pPqJI",
+ "name": "Eyebrow",
+ "fill": "$primary-600",
+ "content": "THE FLYWHEEL",
+ "fontFamily": "$font-display",
+ "fontSize": 15,
+ "fontWeight": "600",
+ "letterSpacing": 4
+ },
+ {
+ "type": "frame",
+ "id": "RVPx4",
+ "name": "Headline",
+ "layout": "vertical",
+ "gap": 2,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "v5Ybmq",
+ "name": "L1",
+ "fill": "$ink",
+ "content": "Solved problems",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 64,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "g7h8rS",
+ "name": "L2",
+ "fill": "$primary-600",
+ "content": "financing",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 64,
+ "fontWeight": "700"
+ },
+ {
+ "type": "text",
+ "id": "dH3Dh",
+ "name": "L3",
+ "fill": "$ink",
+ "content": "unsolved ones.",
+ "lineHeight": 1,
+ "fontFamily": "$font-display",
+ "fontSize": 64,
+ "fontWeight": "700"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "PvbrZ",
+ "name": "Flywheel",
+ "width": 720,
+ "height": 720,
+ "layout": "none",
+ "children": [
+ {
+ "type": "ellipse",
+ "id": "r8Dvl",
+ "x": 105,
+ "y": 105,
+ "name": "Track",
+ "width": 510,
+ "height": 510,
+ "stroke": "$primary-200",
+ "strokeWidth": 2
+ },
+ {
+ "type": "frame",
+ "id": "yRApb",
+ "layoutPosition": "absolute",
+ "x": 316,
+ "y": 316,
+ "name": "Hub",
+ "width": 88,
+ "height": 88,
+ "fill": "$primary-50",
+ "cornerRadius": 44,
+ "stroke": "$primary-200",
+ "strokeWidth": 1,
+ "layout": "vertical",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "sGlFA",
+ "name": "Hub Icon",
+ "width": 38,
+ "height": 38,
+ "icon": "refresh-cw",
+ "library": "lucide",
+ "fill": "$primary-600"
+ }
+ ]
+ },
+ {
+ "type": "icon",
+ "id": "m5GIN",
+ "layoutPosition": "absolute",
+ "x": 514,
+ "y": 160,
+ "name": "Arrow arrow-down-right",
+ "width": 46,
+ "height": 46,
+ "icon": "arrow-down-right",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "icon",
+ "id": "knFr9",
+ "layoutPosition": "absolute",
+ "x": 514,
+ "y": 514,
+ "name": "Arrow arrow-down-left",
+ "width": 46,
+ "height": 46,
+ "icon": "arrow-down-left",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "icon",
+ "id": "sfN3R",
+ "layoutPosition": "absolute",
+ "x": 160,
+ "y": 514,
+ "name": "Arrow arrow-up-left",
+ "width": 46,
+ "height": 46,
+ "icon": "arrow-up-left",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "icon",
+ "id": "FZRnJ",
+ "layoutPosition": "absolute",
+ "x": 160,
+ "y": 160,
+ "name": "Arrow arrow-up-right",
+ "width": 46,
+ "height": 46,
+ "icon": "arrow-up-right",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "frame",
+ "id": "NLJFG",
+ "layoutPosition": "absolute",
+ "x": 260,
+ "y": 50,
+ "name": "Node Open knowledge",
+ "width": 200,
+ "height": 110,
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$primary-600",
+ "strokeWidth": 2,
+ "layout": "vertical",
+ "gap": 10,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "o0jDSJ",
+ "name": "Icon",
+ "width": 30,
+ "height": 30,
+ "icon": "book-open",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "LpmXY",
+ "name": "Label",
+ "fill": "$ink",
+ "content": "Open knowledge",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "Ngtd5",
+ "layoutPosition": "absolute",
+ "x": 515,
+ "y": 305,
+ "name": "Node Open funding",
+ "width": 200,
+ "height": 110,
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$primary-600",
+ "strokeWidth": 2,
+ "layout": "vertical",
+ "gap": 10,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "MQAv6",
+ "name": "Icon",
+ "width": 30,
+ "height": 30,
+ "icon": "circle-dollar-sign",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "Iawen",
+ "name": "Label",
+ "fill": "$ink",
+ "content": "Open funding",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "r8lbO",
+ "layoutPosition": "absolute",
+ "x": 260,
+ "y": 560,
+ "name": "Node Open ventures",
+ "width": 200,
+ "height": 110,
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$primary-600",
+ "strokeWidth": 2,
+ "layout": "vertical",
+ "gap": 10,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "GWpeK",
+ "name": "Icon",
+ "width": 30,
+ "height": 30,
+ "icon": "rocket",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "zBx2W",
+ "name": "Label",
+ "fill": "$ink",
+ "content": "Open ventures",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "ZmV9g",
+ "layoutPosition": "absolute",
+ "x": 5,
+ "y": 305,
+ "name": "Node Repeat",
+ "width": 200,
+ "height": 110,
+ "fill": "$surface",
+ "cornerRadius": "$radius-lg",
+ "stroke": "$primary-600",
+ "strokeWidth": 2,
+ "layout": "vertical",
+ "gap": 10,
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "w4JqY",
+ "name": "Icon",
+ "width": 30,
+ "height": 30,
+ "icon": "rotate-cw",
+ "library": "lucide",
+ "fill": "$primary-600"
+ },
+ {
+ "type": "text",
+ "id": "CJXMp",
+ "name": "Label",
+ "fill": "$ink",
+ "content": "Repeat",
+ "fontFamily": "$font-display",
+ "fontSize": 20,
+ "fontWeight": "600"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "KlGBu",
+ "name": "Footer",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "icon",
+ "id": "tNnCQ",
+ "name": "Swipe",
+ "width": 36,
+ "height": 36,
+ "icon": "arrow-right",
+ "library": "lucide",
+ "fill": "$ink"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "S8lTvN",
+ "name": "08 CTA",
+ "clip": true,
+ "width": 1080,
+ "height": 1350,
+ "fill": [
+ "$neutral-950",
+ {
+ "type": "shader",
+ "opacity": 0.08,
+ "enabled": true,
+ "url": "grain.glsl"
+ }
+ ],
+ "layout": "vertical",
+ "padding": [
+ 64,
+ 80,
+ 56,
+ 80
+ ],
+ "justifyContent": "space_between",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "L1lYo",
+ "name": "Top Bar",
+ "width": "fill_container",
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "H3m1h",
+ "name": "Wordmark",
+ "fill": "#ffffff",
+ "content": "communityfix.org",
+ "fontFamily": "$font-display",
+ "fontSize": 22,
+ "fontWeight": "600"
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "YThu0",
+ "name": "Body 08",
+ "width": "fill_container",
+ "height": "fill_container",
+ "layout": "vertical",
+ "gap": 56,
+ "padding": [
+ 40,
+ 0
+ ],
+ "justifyContent": "center",
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "a6Tjl",
+ "name": "Headline Group",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 22,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "ZHVDz",
+ "name": "Kicker",
+ "fill": "$primary-400",
+ "content": "YOUR TURN",
+ "fontFamily": "$font-display",
+ "fontSize": 16,
+ "fontWeight": "600",
+ "letterSpacing": 5
+ },
+ {
+ "type": "text",
+ "id": "ZM1jX",
+ "name": "Headline",
+ "fill": "#ffffff",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "Your community's next fix is already out there.",
+ "lineHeight": 1.08,
+ "textAlign": "center",
+ "fontFamily": "$font-display",
+ "fontSize": 62,
+ "fontWeight": "700"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "i2zWQ",
+ "name": "Punch",
+ "fill": "$primary-400",
+ "content": "Go find it.",
+ "lineHeight": 1,
+ "textAlign": "center",
+ "fontFamily": "$font-display",
+ "fontSize": 104,
+ "fontWeight": "700"
+ },
+ {
+ "type": "frame",
+ "id": "P22kQ",
+ "name": "CTA Group",
+ "width": "fill_container",
+ "layout": "vertical",
+ "gap": 18,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "frame",
+ "id": "v94C2Z",
+ "name": "URL Wrap",
+ "width": 360,
+ "layout": "vertical",
+ "gap": 12,
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "vPMzj",
+ "name": "URL",
+ "fill": "#ffffff",
+ "textGrowth": "fixed-width",
+ "width": "fill_container",
+ "content": "communityfix.org",
+ "textAlign": "center",
+ "fontFamily": "$font-display",
+ "fontSize": 48,
+ "fontWeight": "600"
+ },
+ {
+ "type": "rectangle",
+ "cornerRadius": 5,
+ "id": "saHOm",
+ "name": "Underline",
+ "fill": "$primary-500",
+ "width": "fill_container",
+ "height": 9
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "id": "in621",
+ "name": "Free",
+ "fill": "$neutral-400",
+ "content": "Free & open.",
+ "fontFamily": "$font-body",
+ "fontSize": 24,
+ "fontWeight": "500"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "SGDFJ",
+ "name": "Footer",
+ "width": "fill_container",
+ "height": 36
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "variables": {
+ "primary-50": {
+ "type": "color",
+ "value": "#eff6ff"
+ },
+ "primary-100": {
+ "type": "color",
+ "value": "#dbeafe"
+ },
+ "primary-200": {
+ "type": "color",
+ "value": "#bfdbfe"
+ },
+ "primary-300": {
+ "type": "color",
+ "value": "#93c5fd"
+ },
+ "primary-400": {
+ "type": "color",
+ "value": "#60a5fa"
+ },
+ "primary-500": {
+ "type": "color",
+ "value": "#3b82f6"
+ },
+ "primary-600": {
+ "type": "color",
+ "value": "#2563eb"
+ },
+ "primary-700": {
+ "type": "color",
+ "value": "#1d4ed8"
+ },
+ "primary-800": {
+ "type": "color",
+ "value": "#1e40af"
+ },
+ "primary-900": {
+ "type": "color",
+ "value": "#1e3a8a"
+ },
+ "primary-950": {
+ "type": "color",
+ "value": "#172554"
+ },
+ "neutral-50": {
+ "type": "color",
+ "value": "#fafafa"
+ },
+ "neutral-100": {
+ "type": "color",
+ "value": "#f5f5f5"
+ },
+ "neutral-200": {
+ "type": "color",
+ "value": "#e5e5e5"
+ },
+ "neutral-300": {
+ "type": "color",
+ "value": "#d4d4d4"
+ },
+ "neutral-400": {
+ "type": "color",
+ "value": "#a3a3a3"
+ },
+ "neutral-500": {
+ "type": "color",
+ "value": "#737373"
+ },
+ "neutral-600": {
+ "type": "color",
+ "value": "#525252"
+ },
+ "neutral-700": {
+ "type": "color",
+ "value": "#404040"
+ },
+ "neutral-800": {
+ "type": "color",
+ "value": "#262626"
+ },
+ "neutral-900": {
+ "type": "color",
+ "value": "#171717"
+ },
+ "neutral-950": {
+ "type": "color",
+ "value": "#0a0a0a"
+ },
+ "ink": {
+ "type": "color",
+ "value": "#0a0a0a"
+ },
+ "surface": {
+ "type": "color",
+ "value": "#ffffff"
+ },
+ "surface-subtle": {
+ "type": "color",
+ "value": "#fafafa"
+ },
+ "text-primary": {
+ "type": "color",
+ "value": "#000000"
+ },
+ "text-secondary": {
+ "type": "color",
+ "value": "#525252"
+ },
+ "text-muted": {
+ "type": "color",
+ "value": "#a3a3a3"
+ },
+ "border": {
+ "type": "color",
+ "value": "#e5e5e5"
+ },
+ "font-display": {
+ "type": "string",
+ "value": "Oswald"
+ },
+ "font-body": {
+ "type": "string",
+ "value": "Inter"
+ },
+ "radius-board": {
+ "type": "number",
+ "value": 24
+ },
+ "radius-lg": {
+ "type": "number",
+ "value": 12
+ },
+ "radius-md": {
+ "type": "number",
+ "value": 8
+ }
+ },
+ "fileToken": "40b739cf-d956-4c20-8275-13ceaa70db91"
+}
\ No newline at end of file
diff --git a/designs/grain.glsl b/designs/grain.glsl
new file mode 100644
index 0000000..1de5dd1
--- /dev/null
+++ b/designs/grain.glsl
@@ -0,0 +1,58 @@
+// Textured-wall surface: domain-warped fractal noise (plaster / troweled
+// concrete feel) with a fine pore speckle on top. Subtlety is controlled by
+// the fill's opacity; broad mottling gives the "wall" character.
+
+/**
+ * @resolution
+ */
+uniform vec2 u_resolution;
+
+/**
+ * @label Texture scale
+ * @default 1.0
+ * @range 0.3, 3.0
+ */
+uniform float u_scale;
+
+float hash(vec2 p) {
+ p = fract(p * vec2(123.34, 456.21));
+ p += dot(p, p + 45.32);
+ return fract(p.x * p.y);
+}
+
+float vnoise(vec2 p) {
+ vec2 i = floor(p);
+ vec2 f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ float a = hash(i);
+ float b = hash(i + vec2(1.0, 0.0));
+ float c = hash(i + vec2(0.0, 1.0));
+ float d = hash(i + vec2(1.0, 1.0));
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
+}
+
+float fbm(vec2 p) {
+ float v = 0.0;
+ float amp = 0.5;
+ for (int i = 0; i < 6; i++) {
+ v += amp * vnoise(p);
+ p = p * 2.0 + vec2(11.7, 3.1);
+ amp *= 0.5;
+ }
+ return v;
+}
+
+void main() {
+ // Broad wall-scale features (tens of px), warped for an organic plaster look.
+ vec2 uv = gl_FragCoord.xy / (95.0 * u_scale);
+ vec2 q = vec2(fbm(uv), fbm(uv + vec2(5.2, 1.3)));
+ float base = fbm(uv + 0.85 * q);
+
+ // Fine pore speckle layered on top.
+ float speck = vnoise(gl_FragCoord.xy * 0.9);
+ float n = mix(base, speck, 0.16);
+
+ // Soft, low-contrast like a painted wall.
+ n = clamp(0.5 + (n - 0.5) * 0.85, 0.0, 1.0);
+ gl_FragColor = vec4(vec3(n), 1.0);
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 525502a..5c4bc09 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -15,23 +15,6 @@ export default defineNuxtConfig({
],
devtools: { enabled: true },
- // Code highlighting theme. Nuxt UI defaults to the washed-out `material-theme`
- // triple; override with a single crisp, high-contrast light theme (the site
- // is light-only — `ui.colorMode: false`).
- mdc: {
- highlight: {
- theme: 'github-light',
- },
- },
-
- // Bind the dev server to IPv4. Without this it listens on `[::1]`, and
- // nuxt-og-image skips its real HTTP font fetch whenever the origin contains
- // `::1` — falling back to an internal route that can't serve @nuxt/fonts'
- // Vite-served `/_fonts/*.woff2`, so every OG image renders with a fallback
- // font. An IPv4 origin lets the renderer fetch fonts over HTTP, matching how
- // production resolves them from the Cloudflare ASSETS binding. Dev-only.
- devServer: { host: '127.0.0.1' },
-
app: {
head: {
htmlAttrs: {
@@ -51,7 +34,16 @@ export default defineNuxtConfig({
},
},
- css: ['@/assets/css/main.css'],
+ css: ['@/assets/css/main.css', '@/assets/css/leaflet.css'],
+
+ // Code highlighting theme. Nuxt UI defaults to the washed-out `material-theme`
+ // triple; override with a single crisp, high-contrast light theme (the site
+ // is light-only — `ui.colorMode: false`).
+ mdc: {
+ highlight: {
+ theme: 'github-light',
+ },
+ },
ui: {
colorMode: false,
@@ -77,6 +69,14 @@ export default defineNuxtConfig({
'/inbox': { redirect: '/dashboard' },
},
+ // Bind the dev server to IPv4. Without this it listens on `[::1]`, and
+ // nuxt-og-image skips its real HTTP font fetch whenever the origin contains
+ // `::1` — falling back to an internal route that can't serve @nuxt/fonts'
+ // Vite-served `/_fonts/*.woff2`, so every OG image renders with a fallback
+ // font. An IPv4 origin lets the renderer fetch fonts over HTTP, matching how
+ // production resolves them from the Cloudflare ASSETS binding. Dev-only.
+ devServer: { host: '127.0.0.1' },
+
future: {
compatibilityVersion: 4,
},
@@ -101,6 +101,11 @@ export default defineNuxtConfig({
'0 3 * * *': ['compute:trust-scores'],
// Reap expired OAuth codes/tokens + stale rate-limit windows at 3:15am UTC
'15 3 * * *': ['oauth:purge'],
+ // Newsletter digests at 8am UTC: weekly on Mondays, monthly on the 1st.
+ // Each task only mails its own frequency cohort; retried/overlapping
+ // invocations are absorbed by the per-user lastSentAt guard.
+ '0 8 * * 1': ['newsletter:weekly'],
+ '0 8 1 * *': ['newsletter:monthly'],
},
// Mount the `auth` namespace on the Cloudflare KV binding so the WebAuthn
// challenge round-trip survives across requests / isolates. The default
diff --git a/public/llms.txt b/public/llms.txt
index 430d88a..692bca2 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -9,7 +9,7 @@ CommunityFix exposes an MCP (Model Context Protocol) server so AI assistants can
## Docs
- [Whitepaper](https://communityfix.org/whitepaper): Vision, problem statement, incubation model, governance, and sustainability metrics
-- [Writing Guide](https://communityfix.org/guide/writing): How to write high-quality issues, solutions, and case studies
+- [Authoring Guide](https://communityfix.org/guide/authoring): Instructions for contributing high-quality issues, solutions, and case studies, for humans and AI agents
- [MCP Server](https://communityfix.org/mcp): How to connect Claude Desktop, Claude Code, and other AI clients via the Model Context Protocol
- [Privacy Policy](https://communityfix.org/privacy): Data handling and privacy practices
- [Terms of Service](https://communityfix.org/terms): Platform terms and conditions
@@ -53,7 +53,7 @@ AI clients that speak the Model Context Protocol should use the MCP endpoint —
- get_user: Fetch a user profile
- whoami: Get the authenticated user's profile
- get_whitepaper: Read the platform whitepaper (mission, incubation model, governance, impact metrics)
-- get_guide: Read the authoring guides; omit the slug to list them, pass one (e.g. "writing") for the full text
+- get_guide: Read the authoring guide; omit the slug to list available guides, pass one (e.g. "authoring") for the full text
- propose_edit: Propose a change to any issue, solution, or case study — applies live if you own it, otherwise queued as a revision proposal
- list_revisions: List a node's revision history plus any pending proposals you may see
- review_revision: Approve or reject a pending revision proposal on a node you own
diff --git a/server/api/_auth/post-login-redirect.get.ts b/server/api/_auth/post-login-redirect.get.ts
index 3f6b43b..b7e738e 100644
--- a/server/api/_auth/post-login-redirect.get.ts
+++ b/server/api/_auth/post-login-redirect.get.ts
@@ -1,5 +1,22 @@
-// Called by AuthForm after a passkey login. Returns the safe same-origin path
-// stored in the redirect cookie (or '' if none / unsafe).
-export default defineEventHandler((event) => {
- return { url: consumePostLoginRedirect(event) ?? '' }
+// Called by AuthForm after a passkey login/registration. Returns the safe
+// same-origin path stored in the redirect cookie; when there is none, sends
+// users who haven't been through onboarding to /onboarding (the explicit
+// redirect always wins so auth gates and the MCP OAuth resume keep working).
+import { eq } from 'drizzle-orm'
+import { users } from '../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const url = consumePostLoginRedirect(event)
+ if (url) return { url }
+
+ const session = await getUserSession(event)
+ if (session.user?.id) {
+ const row = await useDB().query.users.findFirst({
+ where: eq(users.id, session.user.id),
+ columns: { onboardedAt: true },
+ })
+ if (row && !row.onboardedAt) return { url: '/onboarding' }
+ }
+
+ return { url: '' }
})
diff --git a/server/api/admin/case-study/[id]/reject.post.ts b/server/api/admin/case-study/[id]/reject.post.ts
index 339e65e..aae852e 100644
--- a/server/api/admin/case-study/[id]/reject.post.ts
+++ b/server/api/admin/case-study/[id]/reject.post.ts
@@ -3,9 +3,7 @@ import { caseStudies } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
export default defineEventHandler(async (event) => {
- const session = await requireUserSession(event)
- const db = useDB()
- const id = Number(getRouterParam(event, 'id'))
+ const { session, db, id } = await requireEventContext(event)
const body = await readBody<{ reason: string }>(event)
if (!body.reason?.trim()) {
diff --git a/server/api/admin/issues/[id]/appeal.patch.ts b/server/api/admin/issues/[id]/appeal.patch.ts
index ab2d8c7..79a4600 100644
--- a/server/api/admin/issues/[id]/appeal.patch.ts
+++ b/server/api/admin/issues/[id]/appeal.patch.ts
@@ -1,13 +1,10 @@
import { eq } from 'drizzle-orm'
-import { sql } from 'drizzle-orm'
import { issues } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
import { triggerModeration } from '../../../../utils/moderation-trigger'
export default defineEventHandler(async (event) => {
- const session = await requireUserSession(event)
- const db = useDB()
- const id = Number(getRouterParam(event, 'id'))
+ const { session, db, id } = await requireEventContext(event)
const body = await readBody<{ status: 'approved' | 'denied'; reason?: string }>(event)
if (!['approved', 'denied'].includes(body.status)) {
@@ -31,13 +28,7 @@ export default defineEventHandler(async (event) => {
})
.where(eq(issues.id, id))
- if (issue.parentId) {
- const counter =
- issue.type === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} + 1` }
- : { subIssueCount: sql`${issues.subIssueCount} + 1` }
- await db.update(issues).set(counter).where(eq(issues.id, issue.parentId))
- }
+ await adjustParentCounter(db, issue, 1)
await triggerModeration('structure', id)
} else {
diff --git a/server/api/admin/issues/[id]/approve.post.ts b/server/api/admin/issues/[id]/approve.post.ts
index d4eb589..5e9510f 100644
--- a/server/api/admin/issues/[id]/approve.post.ts
+++ b/server/api/admin/issues/[id]/approve.post.ts
@@ -1,5 +1,4 @@
import { eq, and } from 'drizzle-orm'
-import { sql } from 'drizzle-orm'
import { issues, auditLogs, type IssueType, ISSUE_TYPES } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
import { triggerModeration } from '../../../../utils/moderation-trigger'
@@ -78,14 +77,8 @@ export default defineEventHandler(async (event) => {
await db.update(issues).set(updates).where(eq(issues.id, id))
- if (issue.parentId) {
- const effectiveType = (updates.type as IssueType | undefined) ?? issue.type
- const counter =
- effectiveType === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} + 1` }
- : { subIssueCount: sql`${issues.subIssueCount} + 1` }
- await db.update(issues).set(counter).where(eq(issues.id, issue.parentId))
- }
+ const effectiveType = (updates.type as IssueType | undefined) ?? issue.type
+ await adjustParentCounter(db, { parentId: issue.parentId, type: effectiveType }, 1)
await createAuditLog({
type: 'admin_override',
diff --git a/server/api/admin/issues/[id]/reject.post.ts b/server/api/admin/issues/[id]/reject.post.ts
index b288ee1..3cb2241 100644
--- a/server/api/admin/issues/[id]/reject.post.ts
+++ b/server/api/admin/issues/[id]/reject.post.ts
@@ -1,32 +1,22 @@
import { eq } from 'drizzle-orm'
-import { sql } from 'drizzle-orm'
import { issues } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
export default defineEventHandler(async (event) => {
- const session = await requireUserSession(event)
- const db = useDB()
- const id = Number(getRouterParam(event, 'id'))
+ const { session, db, id } = await requireEventContext(event)
const body = await readBody<{ reason: string }>(event)
if (!body.reason) {
throw createError({ statusCode: 400, message: 'Rejection reason is required' })
}
- const issue = await db.query.issues.findFirst({ where: eq(issues.id, id) })
- if (!issue) {
- throw createError({ statusCode: 404, message: 'Issue not found' })
- }
+ const issue = await loadIssueOr404(id)
if (issue.status === 'rejected') {
throw createError({ statusCode: 400, message: 'Issue is already rejected' })
}
- if (issue.parentId && issue.status === 'approved') {
- const counter =
- issue.type === 'solution'
- ? { solutionCount: sql`GREATEST(${issues.solutionCount} - 1, 0)` }
- : { subIssueCount: sql`GREATEST(${issues.subIssueCount} - 1, 0)` }
- await db.update(issues).set(counter).where(eq(issues.id, issue.parentId))
+ if (issue.status === 'approved') {
+ await adjustParentCounter(db, issue, -1)
}
await db
diff --git a/server/api/admin/issues/[id]/remod.post.ts b/server/api/admin/issues/[id]/remod.post.ts
index 212b3c8..53ce111 100644
--- a/server/api/admin/issues/[id]/remod.post.ts
+++ b/server/api/admin/issues/[id]/remod.post.ts
@@ -1,5 +1,4 @@
import { eq } from 'drizzle-orm'
-import { sql } from 'drizzle-orm'
import { issues, issueTags, issueSdgs } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
import { triggerModeration } from '../../../../utils/moderation-trigger'
@@ -16,12 +15,8 @@ export default defineEventHandler(async (event) => {
const previousStatus = issue.status
- if (previousStatus === 'rejected' && issue.parentId) {
- const counter =
- issue.type === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} + 1` }
- : { subIssueCount: sql`${issues.subIssueCount} + 1` }
- await db.update(issues).set(counter).where(eq(issues.id, issue.parentId))
+ if (previousStatus === 'rejected') {
+ await adjustParentCounter(db, issue, 1)
}
await db.delete(issueTags).where(eq(issueTags.issueId, id))
diff --git a/server/api/admin/issues/[id]/request-info.post.ts b/server/api/admin/issues/[id]/request-info.post.ts
index d5b7655..cb8f07f 100644
--- a/server/api/admin/issues/[id]/request-info.post.ts
+++ b/server/api/admin/issues/[id]/request-info.post.ts
@@ -3,19 +3,14 @@ import { issues } from '../../../../database/schema'
import { createAuditLog } from '../../../../utils/audit-log'
export default defineEventHandler(async (event) => {
- const session = await requireUserSession(event)
- const db = useDB()
- const id = Number(getRouterParam(event, 'id'))
+ const { session, db, id } = await requireEventContext(event)
const body = await readBody<{ question: string }>(event)
if (!body.question?.trim()) {
throw createError({ statusCode: 400, message: 'Question is required' })
}
- const issue = await db.query.issues.findFirst({ where: eq(issues.id, id) })
- if (!issue) {
- throw createError({ statusCode: 404, message: 'Issue not found' })
- }
+ const issue = await loadIssueOr404(id)
if (issue.status !== 'pending') {
throw createError({ statusCode: 400, message: 'Can only request info on pending issues' })
}
diff --git a/server/api/admin/logs/[id].get.ts b/server/api/admin/logs/[id].get.ts
new file mode 100644
index 0000000..caadb31
--- /dev/null
+++ b/server/api/admin/logs/[id].get.ts
@@ -0,0 +1,33 @@
+import { eq } from 'drizzle-orm'
+import { auditLogs } from '../../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const db = useDB()
+ const id = Number(getRouterParam(event, 'id'))
+
+ const log = await db.query.auditLogs.findFirst({
+ where: eq(auditLogs.id, id),
+ with: {
+ issue: {
+ columns: {
+ id: true,
+ title: true,
+ summary: true,
+ type: true,
+ status: true,
+ rejectionReason: true,
+ appealStatus: true,
+ appealReason: true,
+ },
+ },
+ user: { columns: { id: true, name: true, email: true, trustScore: true, bannedUntil: true } },
+ reviewer: { columns: { id: true, name: true } },
+ },
+ })
+
+ if (!log) {
+ throw createError({ statusCode: 404, message: 'Audit log not found' })
+ }
+
+ return log
+})
diff --git a/server/api/case-study/[id].patch.ts b/server/api/case-study/[id].patch.ts
index 0888077..9899d01 100644
--- a/server/api/case-study/[id].patch.ts
+++ b/server/api/case-study/[id].patch.ts
@@ -17,11 +17,7 @@ import { isSessionAdmin } from '../../utils/is-admin'
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid case study ID' })
- }
- const caseStudyId = parseInt(id, 10)
+ const caseStudyId = requireIdParam(event, { label: 'case study' })
const { note, ...fields } = await readBody & { note?: string | null }>(
event,
diff --git a/server/api/case-study/[id]/members.get.ts b/server/api/case-study/[id]/members.get.ts
index cbb866b..ed8f133 100644
--- a/server/api/case-study/[id]/members.get.ts
+++ b/server/api/case-study/[id]/members.get.ts
@@ -6,11 +6,7 @@ import { membersWithViewer } from '../../../utils/node-members'
import { getIsAdmin } from '../../../utils/is-admin'
export default defineEventHandler(async (event) => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid case study ID' })
- }
- const caseStudyId = parseInt(id, 10)
+ const caseStudyId = requireIdParam(event, { label: 'case study' })
const node = await useDB().query.caseStudies.findFirst({
where: eq(caseStudies.id, caseStudyId),
diff --git a/server/api/case-study/[id]/members.post.ts b/server/api/case-study/[id]/members.post.ts
index f4dd3b6..a755d77 100644
--- a/server/api/case-study/[id]/members.post.ts
+++ b/server/api/case-study/[id]/members.post.ts
@@ -8,11 +8,7 @@ import { isSessionAdmin } from '../../../utils/is-admin'
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid case study ID' })
- }
- const caseStudyId = parseInt(id, 10)
+ const caseStudyId = requireIdParam(event, { label: 'case study' })
const body = await readBody<{ userId?: string; role?: string }>(event)
if (!body?.userId || !body.role || !NODE_MEMBER_ROLES.includes(body.role as NodeMemberRole)) {
diff --git a/server/api/case-study/[id]/revisions.get.ts b/server/api/case-study/[id]/revisions.get.ts
index 0d2d415..7b376f2 100644
--- a/server/api/case-study/[id]/revisions.get.ts
+++ b/server/api/case-study/[id]/revisions.get.ts
@@ -8,11 +8,7 @@ import { getIsAdmin } from '../../../utils/is-admin'
import { isNodeOwner } from '../../../utils/node-members'
export default defineEventHandler(async (event) => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid case study ID' })
- }
- const caseStudyId = parseInt(id, 10)
+ const caseStudyId = requireIdParam(event, { label: 'case study' })
const db = useDB()
const node = await db.query.caseStudies.findFirst({
diff --git a/server/api/contribute.get.ts b/server/api/contribute.get.ts
new file mode 100644
index 0000000..4ebd780
--- /dev/null
+++ b/server/api/contribute.get.ts
@@ -0,0 +1,8 @@
+import { listNodesNeedingHelp } from '../utils/discovery'
+
+// Nodes the moderation pipeline flagged as needing contribution (help-wanted
+// labels). Backs the /contribute view. Query params: `label` (one help label,
+// omit for any), `kind` (issue | solution | case_study | any), `limit`. Returns
+// the matching issues/solutions and case studies plus per-label facet counts.
+// Logic lives in listNodesNeedingHelp (server/utils/discovery.ts).
+export default defineEventHandler((event) => listNodesNeedingHelp(getQuery(event)))
diff --git a/server/api/issue/[id].patch.ts b/server/api/issue/[id].patch.ts
index 7409155..4fa3a3e 100644
--- a/server/api/issue/[id].patch.ts
+++ b/server/api/issue/[id].patch.ts
@@ -34,11 +34,7 @@ interface IssuePatchBody {
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
- const issueId = parseInt(id, 10)
+ const issueId = requireIdParam(event)
const { note, ...fields } = await readBody(event)
diff --git a/server/api/issue/[id]/case-studies.get.ts b/server/api/issue/[id]/case-studies.get.ts
index e49195e..70e817f 100644
--- a/server/api/issue/[id]/case-studies.get.ts
+++ b/server/api/issue/[id]/case-studies.get.ts
@@ -7,13 +7,10 @@ import { transformCaseStudy } from '../../../utils/case-study-write'
// - issue id → aggregated across all approved solution children
export default defineEventHandler(async (event) => {
const db = useDB()
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
+ const issueId = requireIdParam(event)
const root = await db.query.issues.findFirst({
- where: eq(issues.id, parseInt(id, 10)),
+ where: eq(issues.id, issueId),
columns: { id: true, type: true },
})
if (!root) return []
diff --git a/server/api/issue/[id]/issues.get.ts b/server/api/issue/[id]/issues.get.ts
index d5d830a..14cfb4a 100644
--- a/server/api/issue/[id]/issues.get.ts
+++ b/server/api/issue/[id]/issues.get.ts
@@ -1,39 +1,15 @@
-import { eq, ne, and, desc, asc, sql } from 'drizzle-orm'
+import { eq, ne, sql } from 'drizzle-orm'
import { issues } from '../../../database/schema'
export default defineEventHandler(async (event) => {
- const db = useDB()
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
+ const issueId = requireIdParam(event)
const query = getQuery(event)
const sortBy = (query.sort as string) || 'trending'
const searchTerm = (query.search as string) || ''
- let orderByClause
- switch (sortBy) {
- case 'oldest':
- orderByClause = asc(issues.createdAt)
- break
- case 'most_voted':
- orderByClause = desc(issues.voteScore)
- break
- case 'newest':
- orderByClause = desc(issues.createdAt)
- break
- case 'trending':
- default:
- // Trending formula — see server/api/issues.get.ts and docs/ranking-and-trust.md
- orderByClause = sql`(
- ${issues.voteScore} + ${issues.solutionCount} * 3 + ${issues.subIssueCount} * 2
- )::float / POWER(EXTRACT(EPOCH FROM (NOW() - ${issues.createdAt})) / 3600 + 2, 1.5) DESC`
- break
- }
-
const conditions = [
- eq(issues.parentId, parseInt(id, 10)),
+ eq(issues.parentId, issueId),
ne(issues.status, 'rejected'),
eq(issues.type, 'issue'),
]
@@ -42,14 +18,5 @@ export default defineEventHandler(async (event) => {
conditions.push(sql`search_vector @@ plainto_tsquery('english', ${searchTerm.trim()})`)
}
- const results = await db.query.issues.findMany({
- where: and(...conditions),
- with: issueWithRelations,
- orderBy: orderByClause,
- })
-
- return withMembers(
- 'issue',
- results.map((i) => transformIssue(i)),
- )
+ return listIssueNodes(conditions, sortBy, 'trending')
})
diff --git a/server/api/issue/[id]/members.get.ts b/server/api/issue/[id]/members.get.ts
index b14ee83..e7c11b8 100644
--- a/server/api/issue/[id]/members.get.ts
+++ b/server/api/issue/[id]/members.get.ts
@@ -1,23 +1,12 @@
// Owners + collaborators of an issue/solution, plus whether the viewer may
// manage them. Public list (avatars are already public on cards); the `viewer`
// block gates the management controls.
-import { eq } from 'drizzle-orm'
-import { issues } from '../../../database/schema'
import { membersWithViewer } from '../../../utils/node-members'
import { getIsAdmin } from '../../../utils/is-admin'
export default defineEventHandler(async (event) => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
- const issueId = parseInt(id, 10)
-
- const node = await useDB().query.issues.findFirst({
- where: eq(issues.id, issueId),
- columns: { id: true },
- })
- if (!node) throw createError({ statusCode: 404, statusMessage: `Issue ${issueId} not found` })
+ const issueId = requireIdParam(event)
+ await assertIssueExists(issueId)
const session = await getUserSession(event)
const viewerId = session.user?.id ?? null
diff --git a/server/api/issue/[id]/members.post.ts b/server/api/issue/[id]/members.post.ts
index cbcc94b..43cb593 100644
--- a/server/api/issue/[id]/members.post.ts
+++ b/server/api/issue/[id]/members.post.ts
@@ -1,30 +1,21 @@
// Promote a collaborator to owner (or demote an owner to collaborator) on an
// issue/solution. Owner/admin only; can't strip the last owner. See
// changeMemberRole for the rules.
-import { eq } from 'drizzle-orm'
-import { issues, NODE_MEMBER_ROLES } from '../../../database/schema'
+import { NODE_MEMBER_ROLES } from '../../../database/schema'
import type { NodeMemberRole } from '../../../database/schema'
import { changeMemberRole, membersWithViewer } from '../../../utils/node-members'
import { isSessionAdmin } from '../../../utils/is-admin'
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
- const issueId = parseInt(id, 10)
+ const issueId = requireIdParam(event)
const body = await readBody<{ userId?: string; role?: string }>(event)
if (!body?.userId || !body.role || !NODE_MEMBER_ROLES.includes(body.role as NodeMemberRole)) {
throw createError({ statusCode: 400, statusMessage: 'userId and a valid role are required' })
}
- const node = await useDB().query.issues.findFirst({
- where: eq(issues.id, issueId),
- columns: { id: true },
- })
- if (!node) throw createError({ statusCode: 404, statusMessage: `Issue ${issueId} not found` })
+ await assertIssueExists(issueId)
const isAdmin = await isSessionAdmin(event)
await changeMemberRole({
diff --git a/server/api/issue/[id]/respond-info.post.ts b/server/api/issue/[id]/respond-info.post.ts
index f7b542e..b5ddc80 100644
--- a/server/api/issue/[id]/respond-info.post.ts
+++ b/server/api/issue/[id]/respond-info.post.ts
@@ -3,19 +3,14 @@ import { issues } from '../../../database/schema'
import { triggerModeration } from '../../../utils/moderation-trigger'
export default defineEventHandler(async (event) => {
- const session = await requireUserSession(event)
- const db = useDB()
- const id = Number(getRouterParam(event, 'id'))
+ const { session, db, id } = await requireEventContext(event)
const body = await readBody<{ response: string }>(event)
if (!body.response?.trim()) {
throw createError({ statusCode: 400, message: 'Response is required' })
}
- const issue = await db.query.issues.findFirst({ where: eq(issues.id, id) })
- if (!issue) {
- throw createError({ statusCode: 404, message: 'Issue not found' })
- }
+ const issue = await loadIssueOr404(id)
if (issue.authorId !== session.user.id) {
throw createError({ statusCode: 403, message: 'Only the author can respond' })
}
diff --git a/server/api/issue/[id]/revisions.get.ts b/server/api/issue/[id]/revisions.get.ts
index 548bffb..944e01e 100644
--- a/server/api/issue/[id]/revisions.get.ts
+++ b/server/api/issue/[id]/revisions.get.ts
@@ -2,26 +2,16 @@
// rejected, withdrawn, and superseded ones are visible only to the node owner,
// an admin, or the proposer (visibility decision #3 in the plan).
import { eq, desc } from 'drizzle-orm'
-import { issues, revisions } from '../../../database/schema'
+import { revisions } from '../../../database/schema'
import { canViewRevision, serializeRevision } from '../../../utils/revision-write'
import { getIsAdmin } from '../../../utils/is-admin'
import { isNodeOwner } from '../../../utils/node-members'
export default defineEventHandler(async (event) => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
- const issueId = parseInt(id, 10)
+ const issueId = requireIdParam(event)
+ await assertIssueExists(issueId)
const db = useDB()
- const node = await db.query.issues.findFirst({
- where: eq(issues.id, issueId),
- columns: { id: true, authorId: true },
- })
- if (!node) {
- throw createError({ statusCode: 404, statusMessage: `Issue ${issueId} not found` })
- }
const session = await getUserSession(event)
const viewerId = session.user?.id ?? null
diff --git a/server/api/issue/[id]/solutions.get.ts b/server/api/issue/[id]/solutions.get.ts
index d058507..bcfed49 100644
--- a/server/api/issue/[id]/solutions.get.ts
+++ b/server/api/issue/[id]/solutions.get.ts
@@ -1,39 +1,15 @@
-import { eq, ne, and, desc, asc, sql } from 'drizzle-orm'
+import { eq, ne, sql } from 'drizzle-orm'
import { issues } from '../../../database/schema'
export default defineEventHandler(async (event) => {
- const db = useDB()
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
+ const issueId = requireIdParam(event)
const query = getQuery(event)
const sortBy = (query.sort as string) || 'trending'
const searchTerm = (query.search as string) || ''
- let orderByClause
- switch (sortBy) {
- case 'oldest':
- orderByClause = asc(issues.createdAt)
- break
- case 'most_voted':
- orderByClause = desc(issues.voteScore)
- break
- case 'newest':
- orderByClause = desc(issues.createdAt)
- break
- case 'trending':
- default:
- // Trending formula — see server/api/issues.get.ts and docs/ranking-and-trust.md
- orderByClause = sql`(
- ${issues.voteScore} + ${issues.solutionCount} * 3 + ${issues.subIssueCount} * 2
- )::float / POWER(EXTRACT(EPOCH FROM (NOW() - ${issues.createdAt})) / 3600 + 2, 1.5) DESC`
- break
- }
-
const conditions = [
- eq(issues.parentId, parseInt(id, 10)),
+ eq(issues.parentId, issueId),
ne(issues.status, 'rejected'),
eq(issues.type, 'solution'),
]
@@ -42,14 +18,5 @@ export default defineEventHandler(async (event) => {
conditions.push(sql`search_vector @@ plainto_tsquery('english', ${searchTerm.trim()})`)
}
- const results = await db.query.issues.findMany({
- where: and(...conditions),
- with: issueWithRelations,
- orderBy: orderByClause,
- })
-
- return withMembers(
- 'issue',
- results.map((i) => transformIssue(i)),
- )
+ return listIssueNodes(conditions, sortBy, 'trending')
})
diff --git a/server/api/issue/[id]/tree.get.ts b/server/api/issue/[id]/tree.get.ts
index 015d73e..454c301 100644
--- a/server/api/issue/[id]/tree.get.ts
+++ b/server/api/issue/[id]/tree.get.ts
@@ -4,9 +4,5 @@ import type { TreeNode } from '../../../utils/issue-tree'
export type { TreeNode }
export default defineEventHandler(async (event): Promise => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid issue ID' })
- }
- return getIssueTree(parseInt(id, 10))
+ return getIssueTree(requireIdParam(event))
})
diff --git a/server/api/issue/[id]/wanted-skills.get.ts b/server/api/issue/[id]/wanted-skills.get.ts
new file mode 100644
index 0000000..f1bb26c
--- /dev/null
+++ b/server/api/issue/[id]/wanted-skills.get.ts
@@ -0,0 +1,23 @@
+// Public list of skills an issue/solution is looking for. Small payload:
+// the chip label, who added it (name + id so the client can show a delete
+// affordance on own entries), and when.
+import { asc, eq } from 'drizzle-orm'
+import { users, wantedSkills } from '../../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const issueId = requireIdParam(event)
+ await assertIssueExists(issueId)
+
+ return useDB()
+ .select({
+ id: wantedSkills.id,
+ skill: wantedSkills.skill,
+ createdBy: users.name,
+ createdById: wantedSkills.createdBy,
+ createdAt: wantedSkills.createdAt,
+ })
+ .from(wantedSkills)
+ .leftJoin(users, eq(users.id, wantedSkills.createdBy))
+ .where(eq(wantedSkills.issueId, issueId))
+ .orderBy(asc(wantedSkills.createdAt), asc(wantedSkills.id))
+})
diff --git a/server/api/issue/[id]/wanted-skills.post.ts b/server/api/issue/[id]/wanted-skills.post.ts
new file mode 100644
index 0000000..6e2f1b6
--- /dev/null
+++ b/server/api/issue/[id]/wanted-skills.post.ts
@@ -0,0 +1,71 @@
+// Add a wanted skill to an issue/solution. Any logged-in (non-banned) user can
+// flag what a node needs; duplicates are rejected case-insensitively via the
+// unique index on (issue_id, lower(skill)) — see custom migration 0007.
+import { eq, sql } from 'drizzle-orm'
+import { issues, wantedSkills } from '../../../database/schema'
+
+const MIN_SKILL = 2
+const MAX_SKILL = 60
+const MAX_PER_NODE = 10
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ await assertNotBanned(session.user.id)
+
+ const issueId = requireIdParam(event)
+
+ const body = await readBody<{ skill?: string }>(event)
+ const skill = body?.skill?.trim()
+ if (!skill || skill.length < MIN_SKILL) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Skill must be at least ${MIN_SKILL} characters`,
+ })
+ }
+ if (skill.length > MAX_SKILL) {
+ throw createError({ statusCode: 400, statusMessage: `Skill too long (max ${MAX_SKILL})` })
+ }
+
+ const db = useDB()
+ const node = await db.query.issues.findFirst({
+ where: eq(issues.id, issueId),
+ columns: { id: true, status: true },
+ })
+ if (!node || node.status === 'rejected') {
+ throw createError({ statusCode: 404, statusMessage: 'Issue not found' })
+ }
+
+ const existing = await db
+ .select({ n: sql`count(*)::int` })
+ .from(wantedSkills)
+ .where(eq(wantedSkills.issueId, issueId))
+ if (Number(existing[0]?.n ?? 0) >= MAX_PER_NODE) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `A node can list at most ${MAX_PER_NODE} wanted skills`,
+ })
+ }
+
+ // The unique index handles duplicate detection (including concurrent adds):
+ // insert and treat a unique violation as "already listed".
+ try {
+ const [row] = await db
+ .insert(wantedSkills)
+ .values({ issueId, skill, createdBy: session.user.id })
+ .returning()
+ return {
+ id: row!.id,
+ skill: row!.skill,
+ createdBy: session.user.name ?? null,
+ createdById: row!.createdBy,
+ createdAt: row!.createdAt,
+ }
+ } catch (err: any) {
+ // Drizzle wraps the driver error, so the unique-violation code can be on
+ // the error itself or on its cause.
+ if (err?.code === '23505' || err?.cause?.code === '23505') {
+ throw createError({ statusCode: 400, statusMessage: 'This skill is already listed' })
+ }
+ throw err
+ }
+})
diff --git a/server/api/issue/[id]/wanted-skills/[skillId].delete.ts b/server/api/issue/[id]/wanted-skills/[skillId].delete.ts
new file mode 100644
index 0000000..2deb4c7
--- /dev/null
+++ b/server/api/issue/[id]/wanted-skills/[skillId].delete.ts
@@ -0,0 +1,42 @@
+// Remove a wanted skill from an issue/solution. Allowed for whoever added it,
+// the node's author (creator provenance) or a node owner (the membership-based
+// replacement for authorId — see node_members), or an admin.
+import { and, eq } from 'drizzle-orm'
+import { issues, wantedSkills } from '../../../../database/schema'
+import { isNodeOwner } from '../../../../utils/node-members'
+import { isSessionAdmin } from '../../../../utils/is-admin'
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+
+ const issueId = requireIdParam(event)
+ const skillId = requireIdParam(event, { name: 'skillId', label: 'skill' })
+
+ const db = useDB()
+ const row = await db.query.wantedSkills.findFirst({
+ where: and(eq(wantedSkills.id, skillId), eq(wantedSkills.issueId, issueId)),
+ columns: { id: true, createdBy: true },
+ })
+ if (!row) throw createError({ statusCode: 404, statusMessage: 'Wanted skill not found' })
+
+ const node = await db.query.issues.findFirst({
+ where: eq(issues.id, issueId),
+ columns: { authorId: true },
+ })
+
+ const userId = session.user.id
+ const allowed =
+ row.createdBy === userId ||
+ node?.authorId === userId ||
+ (await isNodeOwner(userId, 'issue', issueId)) ||
+ (await isSessionAdmin(event))
+ if (!allowed) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Only the skill creator, the node author, or an admin can remove this',
+ })
+ }
+
+ await db.delete(wantedSkills).where(eq(wantedSkills.id, skillId))
+ return { ok: true }
+})
diff --git a/server/api/issues.get.ts b/server/api/issues.get.ts
index 7f684d9..fd6f143 100644
--- a/server/api/issues.get.ts
+++ b/server/api/issues.get.ts
@@ -1,10 +1,11 @@
-import { eq, isNull, and, inArray, ne, desc, asc, sql } from 'drizzle-orm'
-import { issues, tags as tagsTable, issueTags } from '../database/schema'
+import { eq, isNull, inArray, ne, sql } from 'drizzle-orm'
+import { issues, tags as tagsTable, issueTags, issueSdgs } from '../database/schema'
export default defineEventHandler(async (event) => {
const db = useDB()
const query = getQuery(event)
const tagFilter = query.tag as string | undefined
+ const sdgFilter = query.sdg ? parseInt(query.sdg as string, 10) : undefined
const sortBy = (query.sort as string) || 'most_voted'
const searchTerm = (query.search as string) || ''
const lat = query.lat ? parseFloat(query.lat as string) : undefined
@@ -30,6 +31,20 @@ export default defineEventHandler(async (event) => {
conditions.push(inArray(issues.id, issueIds))
}
+ // SDG filter — moderation maps every approved node to its goals, so
+ // top-level issues always carry their own mapping (same shape as the tag
+ // filter above).
+ if (sdgFilter != null && !isNaN(sdgFilter)) {
+ const junctionRows = await db.query.issueSdgs.findMany({
+ where: eq(issueSdgs.sdgId, sdgFilter),
+ columns: { issueId: true },
+ })
+ const issueIds = junctionRows.map((r) => r.issueId)
+ if (issueIds.length === 0) return []
+
+ conditions.push(inArray(issues.id, issueIds))
+ }
+
// Full-text search
if (searchTerm.trim()) {
conditions.push(sql`search_vector @@ plainto_tsquery('english', ${searchTerm.trim()})`)
@@ -50,35 +65,5 @@ export default defineEventHandler(async (event) => {
)
}
- // Sort
- let orderByClause
- switch (sortBy) {
- case 'oldest':
- orderByClause = asc(issues.createdAt)
- break
- case 'most_voted':
- orderByClause = desc(issues.voteScore)
- break
- case 'trending':
- // HN-style ranking: engagement / (age_hours + 2) ^ gravity
- // Solutions (3x) and sub-issues (2x) weigh more than raw votes.
- // Keep this in sync with server/api/issue/[id]/{issues,solutions}.get.ts
- // and docs/ranking-and-trust.md.
- orderByClause = sql`(
- ${issues.voteScore} + ${issues.solutionCount} * 3 + ${issues.subIssueCount} * 2
- )::float / POWER(EXTRACT(EPOCH FROM (NOW() - ${issues.createdAt})) / 3600 + 2, 1.5) DESC`
- break
- default:
- orderByClause = desc(issues.createdAt)
- }
-
- const results = await db.query.issues.findMany({
- where: and(...conditions),
- with: issueWithRelations,
- orderBy: orderByClause,
- })
- return withMembers(
- 'issue',
- results.map((i) => transformIssue(i)),
- )
+ return listIssueNodes(conditions, sortBy, 'newest')
})
diff --git a/server/api/map.get.ts b/server/api/map.get.ts
new file mode 100644
index 0000000..a758a3c
--- /dev/null
+++ b/server/api/map.get.ts
@@ -0,0 +1,59 @@
+import { and, eq, isNotNull, ne, sql } from 'drizzle-orm'
+import { caseStudies, issues } from '../database/schema'
+
+// Every approved, geolocated node and case study as a lightweight point for
+// the /map explore page. Titles and coordinates only — the popup links to the
+// detail page for everything else.
+export default defineEventHandler(async () => {
+ const db = useDB()
+
+ const nodes = await db
+ .select({
+ id: issues.id,
+ kind: issues.type,
+ title: issues.title,
+ locationName: issues.locationName,
+ lat: sql`ST_Y(${issues.location})`,
+ lng: sql`ST_X(${issues.location})`,
+ })
+ .from(issues)
+ .where(and(eq(issues.status, 'approved'), ne(issues.isSpam, true), isNotNull(issues.location)))
+
+ const studies = await db
+ .select({
+ id: caseStudies.id,
+ locationName: caseStudies.locationName,
+ outcome: caseStudies.outcome,
+ lat: sql`ST_Y(${caseStudies.location})`,
+ lng: sql`ST_X(${caseStudies.location})`,
+ })
+ .from(caseStudies)
+ .where(
+ and(
+ eq(caseStudies.status, 'approved'),
+ ne(caseStudies.isSpam, true),
+ isNotNull(caseStudies.location),
+ ),
+ )
+
+ return [
+ ...nodes.map((n) => ({
+ kind: n.kind as 'issue' | 'solution',
+ id: n.id,
+ title: n.title,
+ locationName: n.locationName,
+ outcome: null as string | null,
+ lat: n.lat,
+ lng: n.lng,
+ })),
+ ...studies.map((s) => ({
+ kind: 'case-study' as const,
+ id: s.id,
+ title: s.locationName,
+ locationName: s.locationName,
+ outcome: s.outcome as string | null,
+ lat: s.lat,
+ lng: s.lng,
+ })),
+ ]
+})
diff --git a/server/api/mcp/index.post.ts b/server/api/mcp/index.post.ts
index 929fea1..75f8cc6 100644
--- a/server/api/mcp/index.post.ts
+++ b/server/api/mcp/index.post.ts
@@ -48,7 +48,11 @@ const SERVER_INSTRUCTIONS = `CommunityFix is a tree of public issues and solutio
- a **solution** — a proposed way to address its parent issue. Solutions are leaves in the tree: they cannot have sub-solutions.
- a **case study** — a structured record of one real-world implementation of a solution (where it was tried, by whom, what happened, metrics, sources, lessons). Case studies attach to a solution and are NOT part of the issue/solution tree.
-Read \`get_whitepaper\` first if you need the platform's mission, principles, and how the catalog is meant to be used. Before authoring anything, read the relevant authoring guide via \`get_guide\` (call it with no \`slug\` to list guides, then fetch one — e.g. \`get_guide({ slug: "writing" })\` covers how to write good issues, solutions, and case studies, choose tags, and scope nodes).
+Read \`get_whitepaper\` first if you need the platform's mission, principles, and how the catalog is meant to be used. Before authoring anything, read the authoring guide via \`get_guide({ slug: "authoring" })\` (call with no \`slug\` to list available guides). It covers how to write and scope issues, solutions, and case studies, the evidence standard, and the writing rules for AI agents.
+
+RULES FOR AI CLIENTS:
+- Pass your exact model id in the optional \`model\` field on every create_*/update_*/propose_edit call.
+- Do not use the em dash character (\`—\`) in generated content. Write sentences that never need it; use a period, a comma, a colon, or parentheses instead.
Tools come in matched groups by node kind:
- create_issue / update_issue — for problems (top-level or sub-issue under any parent)
@@ -168,6 +172,11 @@ const links = {
const summaryDesc =
'Required short plaintext snippet of THIS node only — a real, standalone synopsis (≤280 chars), NOT the first 280 characters of `description`. Distill the description into a complete sentence or two that make sense on their own. Stay strictly in scope.'
+const model = {
+ type: 'string',
+ description: 'AI clients: the exact id of the model generating this content.',
+}
+
const TOOLS = [
{
name: 'search_issues_solutions',
@@ -225,6 +234,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
title: { type: 'string', description: 'One-line statement of the problem.' },
summary: {
type: 'string',
@@ -258,6 +268,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
title: { type: 'string', description: 'One-line statement of the proposed approach.' },
summary: {
type: 'string',
@@ -297,6 +308,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
id: { type: 'integer' },
title: { type: 'string', description: 'One-line statement of the problem.' },
summary: { type: 'string', description: summaryDesc },
@@ -332,6 +344,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
id: { type: 'integer' },
title: { type: 'string', description: 'One-line statement of the proposed approach.' },
summary: { type: 'string', description: summaryDesc },
@@ -443,6 +456,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
solutionId: {
type: 'integer',
description:
@@ -539,6 +553,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
id: { type: 'integer' },
outcome: { type: 'string', enum: [...CASE_STUDY_OUTCOMES] },
locationName: { type: 'string' },
@@ -641,14 +656,14 @@ const TOOLS = [
name: 'get_guide',
title: 'Get authoring guide',
description:
- 'Read the authoring guides for contributing high-quality content (how to write a good issue, solution, or case study; how to choose tags; how to scope nodes). Call with no `slug` to list available guides; pass a `slug` (e.g. "writing") to get the full markdown. CONSULT THE RELEVANT GUIDE BEFORE create_issue / create_solution / create_case_study.',
+ 'Read the authoring guide for contributing high-quality content (how to write and scope issues, solutions, and case studies; the evidence standard; the writing rules for AI agents). Call with no `slug` to list available guides; pass a `slug` (e.g. "authoring") to get the full markdown. CONSULT IT BEFORE create_issue / create_solution / create_case_study.',
annotations: READ,
inputSchema: {
type: 'object',
properties: {
slug: {
type: 'string',
- description: 'Guide slug to fetch (e.g. "writing"). Omit to list all available guides.',
+ description: 'Guide slug to fetch (e.g. "authoring"). Omit to list all available guides.',
},
},
},
@@ -674,6 +689,7 @@ const TOOLS = [
inputSchema: {
type: 'object',
properties: {
+ model,
kind: {
type: 'string',
enum: ['issue', 'solution', 'case_study'],
@@ -812,7 +828,7 @@ interface ToolCtx {
event: H3Event
}
-function auditWrite(tool: string, ctx: ToolCtx, data: unknown) {
+function auditWrite(tool: string, ctx: ToolCtx, data: unknown, model?: unknown) {
const d = data as { id?: number; status?: string }
console.log(
'[mcp.audit]',
@@ -820,6 +836,7 @@ function auditWrite(tool: string, ctx: ToolCtx, data: unknown) {
tool,
userId: ctx.userId,
clientId: ctx.clientId,
+ model: typeof model === 'string' ? model : null,
id: d?.id ?? null,
status: d?.status ?? 'ok',
}),
@@ -890,28 +907,28 @@ async function callTool(
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await createIssueAs(userId, args)
- auditWrite('create_issue', ctx, data)
+ auditWrite('create_issue', ctx, data, args.model)
return wrap(data)
}
case 'create_solution': {
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await createSolutionAs(userId, args)
- auditWrite('create_solution', ctx, data)
+ auditWrite('create_solution', ctx, data, args.model)
return wrap(data)
}
case 'update_issue': {
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await updateIssueAs(userId, args)
- auditWrite('update_issue', ctx, data)
+ auditWrite('update_issue', ctx, data, args.model)
return wrap(data)
}
case 'update_solution': {
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await updateSolutionAs(userId, args)
- auditWrite('update_solution', ctx, data)
+ auditWrite('update_solution', ctx, data, args.model)
return wrap(data)
}
case 'suggest_more': {
@@ -944,14 +961,14 @@ async function callTool(
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await createCaseStudyAs(userId, args)
- auditWrite('create_case_study', ctx, data)
+ auditWrite('create_case_study', ctx, data, args.model)
return wrap(data)
}
case 'update_case_study': {
const limited = await overToolRate('mcp_write', userId, RATE.write)
if (limited) return wrapErr(limited)
const data = await updateCaseStudyAs(userId, args)
- auditWrite('update_case_study', ctx, data)
+ auditWrite('update_case_study', ctx, data, args.model)
return wrap(data)
}
case 'get_whitepaper': {
@@ -974,7 +991,9 @@ async function callTool(
return wrapErr('kind must be one of "issue", "solution", or "case_study"')
}
if (!Number.isInteger(args?.id)) return wrapErr('id must be an integer')
- return wrap(await proposeEditAs(userId, args))
+ const data = await proposeEditAs(userId, args)
+ auditWrite('propose_edit', ctx, data, args.model)
+ return wrap(data)
}
case 'list_revisions': {
if (args?.kind !== 'issue' && args?.kind !== 'solution' && args?.kind !== 'case_study') {
@@ -1071,7 +1090,11 @@ export default defineEventHandler(async (event) => {
}
// Per-user request guard (throws 429 with Retry-After when exhausted).
- await assertRateLimit(event, { bucket: 'mcp_global', identifier: authed.user.id, ...RATE.global })
+ await assertRateLimit(event, {
+ bucket: 'mcp_global',
+ identifier: authed.user.id,
+ ...RATE.global,
+ })
setHeader(event, 'content-type', 'application/json')
setHeader(event, 'cache-control', 'no-store')
diff --git a/server/api/me/interests.get.ts b/server/api/me/interests.get.ts
new file mode 100644
index 0000000..8930e12
--- /dev/null
+++ b/server/api/me/interests.get.ts
@@ -0,0 +1,17 @@
+// The signed-in user's declared interests, newest first. Labels are free text
+// by design (not tag foreign keys) — matching happens via embeddings later.
+import { desc, eq } from 'drizzle-orm'
+import { userInterests } from '../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ const db = useDB()
+
+ const rows = await db.query.userInterests.findMany({
+ where: eq(userInterests.userId, session.user.id),
+ columns: { id: true, label: true, createdAt: true },
+ orderBy: [desc(userInterests.createdAt)],
+ })
+
+ return rows.map((r) => ({ id: r.id, label: r.label, createdAt: r.createdAt }))
+})
diff --git a/server/api/me/interests.post.ts b/server/api/me/interests.post.ts
new file mode 100644
index 0000000..8b711f5
--- /dev/null
+++ b/server/api/me/interests.post.ts
@@ -0,0 +1,71 @@
+// Add a free-text interest for the signed-in user. The label is embedded so
+// newsletters can match it semantically against tags/issues later; if the
+// embedding call fails the row is stored anyway with a null embedding — adding
+// an interest must never block on OpenAI availability. Duplicates are rejected
+// case-insensitively via the unique index on (user_id, lower(label)) — see
+// custom migration 0008.
+import { eq, sql } from 'drizzle-orm'
+import { userInterests } from '../../database/schema'
+import { assertNotBanned } from '../../utils/check-ban'
+import { generateEmbedding } from '../../utils/embeddings'
+
+const MIN_LABEL = 2
+const MAX_LABEL = 60
+const MAX_PER_USER = 10
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ await assertNotBanned(session.user.id)
+
+ const body = await readBody<{ label?: string }>(event)
+ const label = body?.label?.trim()
+ if (!label || label.length < MIN_LABEL) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Interest must be at least ${MIN_LABEL} characters`,
+ })
+ }
+ if (label.length > MAX_LABEL) {
+ throw createError({ statusCode: 400, statusMessage: `Interest too long (max ${MAX_LABEL})` })
+ }
+
+ const db = useDB()
+ const existing = await db
+ .select({ n: sql`count(*)::int` })
+ .from(userInterests)
+ .where(eq(userInterests.userId, session.user.id))
+ if (Number(existing[0]?.n ?? 0) >= MAX_PER_USER) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `You can list at most ${MAX_PER_USER} interests`,
+ })
+ }
+
+ let embedding: number[] | null = null
+ try {
+ embedding = await generateEmbedding(label)
+ } catch (err) {
+ console.error('[me.interests] embedding failed, storing without vector:', err)
+ }
+
+ // The unique index handles duplicate detection (including concurrent adds):
+ // insert and treat a unique violation as "already listed".
+ try {
+ const [row] = await db
+ .insert(userInterests)
+ .values({ userId: session.user.id, label, embedding })
+ .returning({
+ id: userInterests.id,
+ label: userInterests.label,
+ createdAt: userInterests.createdAt,
+ })
+ return row!
+ } catch (err: any) {
+ // drizzle wraps driver errors in DrizzleQueryError; the postgres error
+ // code lives on the cause.
+ if (err?.code === '23505' || err?.cause?.code === '23505') {
+ throw createError({ statusCode: 400, statusMessage: 'This interest is already listed' })
+ }
+ throw err
+ }
+})
diff --git a/server/api/me/interests/[id].delete.ts b/server/api/me/interests/[id].delete.ts
new file mode 100644
index 0000000..a29c839
--- /dev/null
+++ b/server/api/me/interests/[id].delete.ts
@@ -0,0 +1,22 @@
+import { and, eq } from 'drizzle-orm'
+import { userInterests } from '../../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ const id = Number(getRouterParam(event, 'id'))
+ if (!Number.isFinite(id)) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid id' })
+ }
+
+ const db = useDB()
+ const result = await db
+ .delete(userInterests)
+ .where(and(eq(userInterests.id, id), eq(userInterests.userId, session.user.id)))
+ .returning({ id: userInterests.id })
+
+ if (result.length === 0) {
+ throw createError({ statusCode: 404, statusMessage: 'Interest not found' })
+ }
+
+ return { ok: true }
+})
diff --git a/server/api/me/newsletter.get.ts b/server/api/me/newsletter.get.ts
new file mode 100644
index 0000000..2be13c4
--- /dev/null
+++ b/server/api/me/newsletter.get.ts
@@ -0,0 +1,28 @@
+// The signed-in user's newsletter preferences. Returns defaults (disabled)
+// when no row exists yet — the client can't tell the difference and doesn't
+// need to.
+import { eq } from 'drizzle-orm'
+import { newsletterPrefs, type NewsletterContent } from '../../database/schema'
+
+const DEFAULT_NEWSLETTER_CONTENT: NewsletterContent = {
+ goodNews: false,
+ skillMatches: false,
+ topicMatches: false,
+ helpWanted: false,
+ productUpdates: false,
+}
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ const db = useDB()
+
+ const row = await db.query.newsletterPrefs.findFirst({
+ where: eq(newsletterPrefs.userId, session.user.id),
+ })
+
+ return {
+ enabled: row?.enabled ?? false,
+ frequency: row?.frequency ?? 'monthly',
+ content: { ...DEFAULT_NEWSLETTER_CONTENT, ...(row?.content ?? {}) },
+ }
+})
diff --git a/server/api/me/newsletter.put.ts b/server/api/me/newsletter.put.ts
new file mode 100644
index 0000000..f9f6b76
--- /dev/null
+++ b/server/api/me/newsletter.put.ts
@@ -0,0 +1,69 @@
+// Upsert the signed-in user's newsletter preferences. Collection side only —
+// nothing is sent from here.
+import { sql } from 'drizzle-orm'
+import {
+ newsletterPrefs,
+ NEWSLETTER_FREQUENCIES,
+ type NewsletterContent,
+ type NewsletterFrequency,
+} from '../../database/schema'
+
+const CONTENT_KEYS = [
+ 'goodNews',
+ 'skillMatches',
+ 'topicMatches',
+ 'helpWanted',
+ 'productUpdates',
+] as const
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+
+ const body = await readBody<{
+ enabled?: unknown
+ frequency?: unknown
+ content?: unknown
+ }>(event)
+
+ if (typeof body?.enabled !== 'boolean') {
+ throw createError({ statusCode: 400, statusMessage: 'enabled must be a boolean' })
+ }
+ const enabled = body.enabled
+
+ const frequency = (body.frequency ?? 'monthly') as NewsletterFrequency
+ if (!NEWSLETTER_FREQUENCIES.includes(frequency)) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `frequency must be one of: ${NEWSLETTER_FREQUENCIES.join(', ')}`,
+ })
+ }
+
+ const rawContent = (body.content ?? {}) as Record
+ if (typeof rawContent !== 'object' || rawContent === null || Array.isArray(rawContent)) {
+ throw createError({ statusCode: 400, statusMessage: 'content must be an object' })
+ }
+ const content = {} as NewsletterContent
+ for (const key of CONTENT_KEYS) {
+ const value = rawContent[key] ?? false
+ if (typeof value !== 'boolean') {
+ throw createError({ statusCode: 400, statusMessage: `content.${key} must be a boolean` })
+ }
+ content[key] = value
+ }
+
+ const db = useDB()
+ const [row] = await db
+ .insert(newsletterPrefs)
+ .values({ userId: session.user.id, enabled, frequency, content })
+ .onConflictDoUpdate({
+ target: newsletterPrefs.userId,
+ set: { enabled, frequency, content, updatedAt: sql`now()` },
+ })
+ .returning()
+
+ return {
+ enabled: row!.enabled,
+ frequency: row!.frequency,
+ content: row!.content,
+ }
+})
diff --git a/server/api/me/onboarded.post.ts b/server/api/me/onboarded.post.ts
new file mode 100644
index 0000000..87e2c09
--- /dev/null
+++ b/server/api/me/onboarded.post.ts
@@ -0,0 +1,17 @@
+// Stamp users.onboarded_at so the post-login redirect stops sending the user
+// to /onboarding. Idempotent: the first call sets the timestamp, later calls
+// leave it untouched.
+import { and, eq, isNull, sql } from 'drizzle-orm'
+import { users } from '../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event)
+ const db = useDB()
+
+ await db
+ .update(users)
+ .set({ onboardedAt: sql`now()` })
+ .where(and(eq(users.id, session.user.id), isNull(users.onboardedAt)))
+
+ return { ok: true }
+})
diff --git a/server/api/me/overview.get.ts b/server/api/me/overview.get.ts
index 5dc9a42..3ae388c 100644
--- a/server/api/me/overview.get.ts
+++ b/server/api/me/overview.get.ts
@@ -32,7 +32,10 @@ export default defineEventHandler(async (event) => {
ne(issues.status, 'rejected'),
),
),
- db.select({ n: count() }).from(caseStudies).where(eq(caseStudies.authorId, userId)),
+ db
+ .select({ n: count() })
+ .from(caseStudies)
+ .where(and(eq(caseStudies.authorId, userId), ne(caseStudies.status, 'rejected'))),
// Approved edits I proposed on nodes I did NOT author — real collaboration.
db
.select({ n: count() })
diff --git a/server/api/newsletter/unsubscribe.get.ts b/server/api/newsletter/unsubscribe.get.ts
new file mode 100644
index 0000000..ed1e8be
--- /dev/null
+++ b/server/api/newsletter/unsubscribe.get.ts
@@ -0,0 +1,67 @@
+// One-click unsubscribe target for newsletter emails. Must work logged-out:
+// the HMAC-signed token in the link (see newsletter-token.ts) is the proof
+// of account ownership. Idempotent — a second click lands on the same
+// confirmation. Renders a tiny branded HTML page rather than JSON because
+// the visitor arrives from an email client.
+import { eq, sql } from 'drizzle-orm'
+import { newsletterPrefs } from '../../database/schema'
+import { verifyUnsubscribeToken } from '../../utils/newsletter-token'
+
+function page(title: string, body: string, status = 200) {
+ return {
+ status,
+ html: `
+
+
+
+
+ ${title} — CommunityFix
+
+
+
+
+ CommunityFix
+
${title}
+ ${body}
+
+
+`,
+ }
+}
+
+export default defineEventHandler(async (event) => {
+ const q = getQuery(event)
+ const token = typeof q.token === 'string' ? q.token : ''
+
+ setHeader(event, 'content-type', 'text/html; charset=utf-8')
+ setHeader(event, 'cache-control', 'no-store')
+
+ const userId = token ? await verifyUnsubscribeToken(token) : null
+ if (!userId) {
+ const { status, html } = page(
+ 'This unsubscribe link is not valid',
+ `The link may have been truncated by your email client. You can also turn the newsletter off from your account settings .
`,
+ 400,
+ )
+ setResponseStatus(event, status)
+ return html
+ }
+
+ await useDB()
+ .update(newsletterPrefs)
+ .set({ enabled: false, updatedAt: sql`now()` })
+ .where(eq(newsletterPrefs.userId, userId))
+
+ return page(
+ "You're unsubscribed",
+ `You won't receive the newsletter anymore. Changed your mind? Re-enable it anytime in your account settings .
`,
+ ).html
+})
diff --git a/server/api/revisions/[id].get.ts b/server/api/revisions/[id].get.ts
index c41ab9c..5f16e5a 100644
--- a/server/api/revisions/[id].get.ts
+++ b/server/api/revisions/[id].get.ts
@@ -9,11 +9,7 @@ import { getIsAdmin } from '../../utils/is-admin'
import { resolveDecideRole, isNodeOwner } from '../../utils/node-members'
export default defineEventHandler(async (event) => {
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' })
- }
- const revisionId = parseInt(id, 10)
+ const revisionId = requireIdParam(event, { label: 'revision' })
const db = useDB()
const row = await db.query.revisions.findFirst({
diff --git a/server/api/revisions/[id]/approve.post.ts b/server/api/revisions/[id]/approve.post.ts
index 3101108..4bb3207 100644
--- a/server/api/revisions/[id]/approve.post.ts
+++ b/server/api/revisions/[id]/approve.post.ts
@@ -5,17 +5,14 @@ import { decideRevision, serializeRevision } from '../../../utils/revision-write
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' })
- }
+ const revisionId = requireIdParam(event, { label: 'revision' })
const body = await readBody<{ reason?: string | null }>(event).catch(
() => ({}) as { reason?: string | null },
)
const revision = await decideRevision(
session.user.id,
- parseInt(id, 10),
+ revisionId,
'approve',
body?.reason ?? null,
event,
diff --git a/server/api/revisions/[id]/reject.post.ts b/server/api/revisions/[id]/reject.post.ts
index 50e1d24..8c3ad27 100644
--- a/server/api/revisions/[id]/reject.post.ts
+++ b/server/api/revisions/[id]/reject.post.ts
@@ -5,17 +5,14 @@ import { decideRevision, serializeRevision } from '../../../utils/revision-write
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' })
- }
+ const revisionId = requireIdParam(event, { label: 'revision' })
const body = await readBody<{ reason?: string | null }>(event).catch(
() => ({}) as { reason?: string | null },
)
const revision = await decideRevision(
session.user.id,
- parseInt(id, 10),
+ revisionId,
'reject',
body?.reason ?? null,
event,
diff --git a/server/api/revisions/[id]/withdraw.post.ts b/server/api/revisions/[id]/withdraw.post.ts
index 633ef7c..a7fa9c0 100644
--- a/server/api/revisions/[id]/withdraw.post.ts
+++ b/server/api/revisions/[id]/withdraw.post.ts
@@ -5,11 +5,8 @@ import { withdrawRevision, serializeRevision } from '../../../utils/revision-wri
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
- const id = getRouterParam(event, 'id')
- if (!id || isNaN(parseInt(id, 10))) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid revision ID' })
- }
+ const revisionId = requireIdParam(event, { label: 'revision' })
- const revision = await withdrawRevision(session.user.id, parseInt(id, 10))
+ const revision = await withdrawRevision(session.user.id, revisionId)
return { success: true, revision: revision ? serializeRevision(revision) : null }
})
diff --git a/server/api/sdgs.get.ts b/server/api/sdgs.get.ts
index f5f6ffe..266f238 100644
--- a/server/api/sdgs.get.ts
+++ b/server/api/sdgs.get.ts
@@ -1,4 +1,26 @@
+import { and, eq, ne, sql } from 'drizzle-orm'
+import { issues, issueSdgs, sdgs } from '../database/schema'
+
export default defineEventHandler(async () => {
const db = useDB()
- return db.query.sdgs.findMany()
+ // `uses` counts approved, non-spam nodes mapped to each goal so browse UIs
+ // can hide or rank empty goals.
+ return db
+ .select({
+ id: sdgs.id,
+ name: sdgs.name,
+ iconUrl: sdgs.iconUrl,
+ link: sdgs.link,
+ createdAt: sdgs.createdAt,
+ updatedAt: sdgs.updatedAt,
+ uses: sql`count(${issues.id})::int`,
+ })
+ .from(sdgs)
+ .leftJoin(issueSdgs, eq(issueSdgs.sdgId, sdgs.id))
+ .leftJoin(
+ issues,
+ and(eq(issues.id, issueSdgs.issueId), eq(issues.status, 'approved'), ne(issues.isSpam, true)),
+ )
+ .groupBy(sdgs.id)
+ .orderBy(sdgs.id)
})
diff --git a/server/api/search/quick.get.ts b/server/api/search/quick.get.ts
new file mode 100644
index 0000000..33f9b51
--- /dev/null
+++ b/server/api/search/quick.get.ts
@@ -0,0 +1,40 @@
+import { and, desc, eq, ne, sql } from 'drizzle-orm'
+import { caseStudies, issues } from '../../database/schema'
+
+const GROUP_LIMIT = 20
+
+// Catalog-wide full-text search for the site search bar: issues, solutions,
+// AND case studies in one round trip, grouped by kind. Uses the GIN-indexed
+// `search_vector` columns, so it stays fast and needs no embeddings (unlike
+// the semantic /api/search).
+export default defineEventHandler(async (event) => {
+ const q = ((getQuery(event).q as string) || '').trim()
+ if (!q) return { issues: [], solutions: [], caseStudies: [] }
+
+ const db = useDB()
+ const tsQuery = sql`plainto_tsquery('english', ${q})`
+
+ const nodes = await db.query.issues.findMany({
+ where: and(ne(issues.status, 'rejected'), sql`search_vector @@ ${tsQuery}`),
+ with: issueWithRelations,
+ orderBy: [sql`ts_rank(search_vector, ${tsQuery}) DESC`, desc(issues.voteScore)],
+ limit: GROUP_LIMIT * 2,
+ })
+
+ const studies = await db.query.caseStudies.findMany({
+ where: and(eq(caseStudies.status, 'approved'), sql`search_vector @@ ${tsQuery}`),
+ with: {
+ author: { columns: { name: true } },
+ solution: { columns: { title: true, summary: true } },
+ },
+ orderBy: [sql`ts_rank(search_vector, ${tsQuery}) DESC`, desc(caseStudies.createdAt)],
+ limit: GROUP_LIMIT,
+ })
+
+ const transformed = nodes.map((n) => transformIssue(n))
+ return {
+ issues: transformed.filter((n) => n.type === 'issue').slice(0, GROUP_LIMIT),
+ solutions: transformed.filter((n) => n.type === 'solution').slice(0, GROUP_LIMIT),
+ caseStudies: studies.map(transformCaseStudy),
+ }
+})
diff --git a/server/api/solutions.get.ts b/server/api/solutions.get.ts
new file mode 100644
index 0000000..91724b8
--- /dev/null
+++ b/server/api/solutions.get.ts
@@ -0,0 +1,25 @@
+import { eq, ne, sql } from 'drizzle-orm'
+import { issues } from '../database/schema'
+import { SOLUTION_STATUSES } from '../database/schema'
+import type { SolutionStatus } from '../database/schema'
+
+// Global solutions directory: every approved solution across all issues, with
+// the same search/sort surface as /api/issues plus a solutionStatus filter.
+export default defineEventHandler(async (event) => {
+ const query = getQuery(event)
+ const sortBy = (query.sort as string) || 'most_voted'
+ const searchTerm = (query.search as string) || ''
+ const status = query.status as string | undefined
+
+ const conditions = [eq(issues.type, 'solution'), ne(issues.status, 'rejected')]
+
+ if (status && (SOLUTION_STATUSES as readonly string[]).includes(status)) {
+ conditions.push(eq(issues.solutionStatus, status as SolutionStatus))
+ }
+
+ if (searchTerm.trim()) {
+ conditions.push(sql`search_vector @@ plainto_tsquery('english', ${searchTerm.trim()})`)
+ }
+
+ return listIssueNodes(conditions, sortBy, 'most_voted')
+})
diff --git a/server/api/stats.get.ts b/server/api/stats.get.ts
new file mode 100644
index 0000000..37f9a3a
--- /dev/null
+++ b/server/api/stats.get.ts
@@ -0,0 +1,29 @@
+import { sql } from 'drizzle-orm'
+
+// Public catalog counts for the home page stat strip. One round-trip; every
+// count only includes approved, non-spam content.
+export default defineEventHandler(async () => {
+ const db = useDB()
+ const rows = (await db.execute(
+ sql`SELECT
+ (SELECT count(*) FROM issues WHERE type = 'issue' AND status = 'approved' AND is_spam != true)::int AS issues,
+ (SELECT count(*) FROM issues WHERE type = 'solution' AND status = 'approved' AND is_spam != true)::int AS solutions,
+ (SELECT count(*) FROM case_studies WHERE status = 'approved' AND is_spam != true)::int AS case_studies,
+ (SELECT count(DISTINCT tag_id) FROM issue_tags it
+ JOIN issues i ON i.id = it.issue_id
+ WHERE i.status = 'approved' AND i.is_spam != true)::int AS topics`,
+ )) as unknown as Array<{
+ issues: number
+ solutions: number
+ case_studies: number
+ topics: number
+ }>
+
+ const row = rows[0]
+ return {
+ issues: row?.issues ?? 0,
+ solutions: row?.solutions ?? 0,
+ caseStudies: row?.case_studies ?? 0,
+ topics: row?.topics ?? 0,
+ }
+})
diff --git a/server/api/tags.get.ts b/server/api/tags.get.ts
index 0022fde..e99ba93 100644
--- a/server/api/tags.get.ts
+++ b/server/api/tags.get.ts
@@ -1,6 +1,26 @@
+import { and, eq, ne, sql } from 'drizzle-orm'
+import { issues, issueTags, tags } from '../database/schema'
+
export default defineEventHandler(async () => {
const db = useDB()
// Exclude the 1536-dim `embedding` vector — no client needs it, and it would
// otherwise dominate the payload (notably for the public OpenAPI/GPT Action).
- return db.query.tags.findMany({ columns: { embedding: false } })
+ // `uses` counts approved, non-spam nodes so browse UIs can rank topics.
+ return db
+ .select({
+ id: tags.id,
+ slug: tags.slug,
+ name: tags.name,
+ createdAt: tags.createdAt,
+ updatedAt: tags.updatedAt,
+ uses: sql`count(${issues.id})::int`,
+ })
+ .from(tags)
+ .leftJoin(issueTags, eq(issueTags.tagId, tags.id))
+ .leftJoin(
+ issues,
+ and(eq(issues.id, issueTags.issueId), eq(issues.status, 'approved'), ne(issues.isSpam, true)),
+ )
+ .groupBy(tags.id)
+ .orderBy(sql`count(${issues.id}) DESC`, tags.name)
})
diff --git a/server/api/tags/similar.get.ts b/server/api/tags/similar.get.ts
new file mode 100644
index 0000000..c3876e1
--- /dev/null
+++ b/server/api/tags/similar.get.ts
@@ -0,0 +1,28 @@
+// Closest catalog tags to a free-text query, by embedding similarity. Powers
+// the live "targets these topics" preview when a user types an interest.
+// Logged-in only: every call costs an OpenAI embedding, so keep the abuse
+// surface small. Reuses the same search logic as the MCP `search_tags` tool.
+import { searchTags } from '../../utils/mcp-tools'
+
+const MIN_QUERY = 3
+const LIMIT = 5
+
+export default defineEventHandler(async (event) => {
+ await requireUserSession(event)
+
+ const q = String(getQuery(event).q ?? '').trim()
+ if (q.length < MIN_QUERY) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Query must be at least ${MIN_QUERY} characters`,
+ })
+ }
+
+ const { results } = await searchTags({ query: q, limit: LIMIT })
+ return results.map((t) => ({
+ id: t.id,
+ slug: t.slug,
+ name: t.name,
+ similarity: 'similarity' in t ? t.similarity : null,
+ }))
+})
diff --git a/server/database/migrations/0019_boring_menace.sql b/server/database/migrations/0019_boring_menace.sql
new file mode 100644
index 0000000..6e94a64
--- /dev/null
+++ b/server/database/migrations/0019_boring_menace.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "case_studies" ADD COLUMN "help_labels" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
+ALTER TABLE "issues" ADD COLUMN "help_labels" text[] DEFAULT '{}'::text[] NOT NULL;
\ No newline at end of file
diff --git a/server/database/migrations/0020_smart_blur.sql b/server/database/migrations/0020_smart_blur.sql
new file mode 100644
index 0000000..6161bed
--- /dev/null
+++ b/server/database/migrations/0020_smart_blur.sql
@@ -0,0 +1,11 @@
+CREATE TABLE "wanted_skills" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "issue_id" integer NOT NULL,
+ "skill" text NOT NULL,
+ "created_by" uuid,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "wanted_skills" ADD CONSTRAINT "wanted_skills_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "wanted_skills" ADD CONSTRAINT "wanted_skills_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "wanted_skills_issue_id_idx" ON "wanted_skills" USING btree ("issue_id");
\ No newline at end of file
diff --git a/server/database/migrations/0021_vengeful_whizzer.sql b/server/database/migrations/0021_vengeful_whizzer.sql
new file mode 100644
index 0000000..9a8897f
--- /dev/null
+++ b/server/database/migrations/0021_vengeful_whizzer.sql
@@ -0,0 +1,20 @@
+CREATE TABLE "newsletter_prefs" (
+ "user_id" uuid PRIMARY KEY NOT NULL,
+ "enabled" boolean DEFAULT false NOT NULL,
+ "frequency" text DEFAULT 'monthly' NOT NULL,
+ "content" jsonb,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "user_interests" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "user_id" uuid NOT NULL,
+ "label" text NOT NULL,
+ "embedding" vector(1536),
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "onboarded_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "newsletter_prefs" ADD CONSTRAINT "newsletter_prefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "user_interests" ADD CONSTRAINT "user_interests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/server/database/migrations/0022_material_tony_stark.sql b/server/database/migrations/0022_material_tony_stark.sql
new file mode 100644
index 0000000..3f3a909
--- /dev/null
+++ b/server/database/migrations/0022_material_tony_stark.sql
@@ -0,0 +1 @@
+ALTER TABLE "newsletter_prefs" ADD COLUMN "last_sent_at" timestamp with time zone;
\ No newline at end of file
diff --git a/server/database/migrations/custom/0004_revisions_constraints.sql b/server/database/migrations/custom/0004_revisions_constraints.sql
index cb3b24d..5f3537e 100644
--- a/server/database/migrations/custom/0004_revisions_constraints.sql
+++ b/server/database/migrations/custom/0004_revisions_constraints.sql
@@ -17,6 +17,19 @@ DO $$ BEGIN
CHECK (target_kind IN ('issue', 'case_study'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+-- Both columns are nullable (null verdict = not screened, null role = not yet
+-- decided); a CHECK evaluating to NULL passes, so these only reject bad
+-- non-null values.
+DO $$ BEGIN
+ ALTER TABLE revisions ADD CONSTRAINT revisions_ai_verdict_check
+ CHECK (ai_verdict IN ('ok', 'spam', 'vandalism'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+DO $$ BEGIN
+ ALTER TABLE revisions ADD CONSTRAINT revisions_decided_by_role_check
+ CHECK (decided_by_role IN ('owner', 'admin'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
-- One live proposal per user per node — a proposer can't queue two competing
-- pending edits for the same target. Partial so decided rows (approved /
-- rejected / withdrawn / superseded) don't collide. NULLs are distinct in a
diff --git a/server/database/migrations/custom/0006_help_labels_indexes.sql b/server/database/migrations/custom/0006_help_labels_indexes.sql
new file mode 100644
index 0000000..c407601
--- /dev/null
+++ b/server/database/migrations/custom/0006_help_labels_indexes.sql
@@ -0,0 +1,6 @@
+-- GIN indexes for filtering nodes by help-wanted label (array containment /
+-- overlap operators). The contribute view queries `help_labels @> ...` and
+-- `help_labels && ...`, so a GIN index keeps those lookups fast as the catalog
+-- grows. Columns themselves are created by drizzle migration 0019.
+CREATE INDEX IF NOT EXISTS idx_issues_help_labels ON issues USING GIN (help_labels);
+CREATE INDEX IF NOT EXISTS idx_case_studies_help_labels ON case_studies USING GIN (help_labels);
diff --git a/server/database/migrations/custom/0007_wanted_skills_unique.sql b/server/database/migrations/custom/0007_wanted_skills_unique.sql
new file mode 100644
index 0000000..27e7c96
--- /dev/null
+++ b/server/database/migrations/custom/0007_wanted_skills_unique.sql
@@ -0,0 +1,6 @@
+-- Case-insensitive uniqueness for wanted skills per node: the same skill can't
+-- be listed twice on one issue/solution regardless of how it's capitalized
+-- ("GIS" vs "gis"). Expression indexes aren't expressible in drizzle schema,
+-- hence custom. The table itself is created by drizzle migration 0020.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_skills_issue_skill_ci
+ ON wanted_skills (issue_id, lower(skill));
diff --git a/server/database/migrations/custom/0008_user_interests_indexes.sql b/server/database/migrations/custom/0008_user_interests_indexes.sql
new file mode 100644
index 0000000..7b0883f
--- /dev/null
+++ b/server/database/migrations/custom/0008_user_interests_indexes.sql
@@ -0,0 +1,10 @@
+-- Case-insensitive uniqueness for user interests: the same label can't be
+-- declared twice by one user regardless of capitalization ("Beekeeping" vs
+-- "beekeeping"). Expression indexes aren't expressible in drizzle schema,
+-- hence custom. The table itself is created by drizzle migration 0021.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_user_interests_user_label_ci
+ ON user_interests (user_id, lower(label));
+
+-- pgvector HNSW index for cosine similarity (same pattern as custom 0001).
+CREATE INDEX IF NOT EXISTS idx_user_interests_embedding
+ ON user_interests USING hnsw (embedding vector_cosine_ops);
diff --git a/server/database/migrations/custom/0009_newsletter_prefs_constraints.sql b/server/database/migrations/custom/0009_newsletter_prefs_constraints.sql
new file mode 100644
index 0000000..6dad5b5
--- /dev/null
+++ b/server/database/migrations/custom/0009_newsletter_prefs_constraints.sql
@@ -0,0 +1,6 @@
+-- `.$type()` narrows TypeScript only — enforce the enum
+-- in the DB like every other enum text column (see 0002).
+DO $$ BEGIN
+ ALTER TABLE newsletter_prefs ADD CONSTRAINT newsletter_prefs_frequency_check
+ CHECK (frequency IN ('weekly', 'monthly'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
diff --git a/server/database/migrations/meta/0019_snapshot.json b/server/database/migrations/meta/0019_snapshot.json
new file mode 100644
index 0000000..f683a2e
--- /dev/null
+++ b/server/database/migrations/meta/0019_snapshot.json
@@ -0,0 +1,2132 @@
+{
+ "id": "b887336e-3075-4c43-97dc-94d1d5d08b39",
+ "prevId": "bada0bc0-bbd0-4f71-833b-15d06f9350bf",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto_resolved'"
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_type_idx": {
+ "name": "audit_logs_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_status_idx": {
+ "name": "audit_logs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_issue_id_idx": {
+ "name": "audit_logs_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_user_id_idx": {
+ "name": "audit_logs_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_issue_id_issues_id_fk": {
+ "name": "audit_logs_issue_id_issues_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_user_id_users_id_fk": {
+ "name": "audit_logs_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_reviewed_by_users_id_fk": {
+ "name": "audit_logs_reviewed_by_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_studies": {
+ "name": "case_studies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "solution_id": {
+ "name": "solution_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "implementer": {
+ "name": "implementer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metrics": {
+ "name": "metrics",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "funding_source": {
+ "name": "funding_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lessons_learned": {
+ "name": "lessons_learned",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "case_studies_solution_idx": {
+ "name": "case_studies_solution_idx",
+ "columns": [
+ {
+ "expression": "solution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_studies_solution_id_issues_id_fk": {
+ "name": "case_studies_solution_id_issues_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "solution_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_studies_author_id_users_id_fk": {
+ "name": "case_studies_author_id_users_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "credentials_user_id_users_id_fk": {
+ "name": "credentials_user_id_users_id_fk",
+ "tableFrom": "credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_sdgs": {
+ "name": "issue_sdgs",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sdg_id": {
+ "name": "sdg_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_sdgs_issue_id_issues_id_fk": {
+ "name": "issue_sdgs_issue_id_issues_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_sdgs_sdg_id_sdgs_id_fk": {
+ "name": "issue_sdgs_sdg_id_sdgs_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "sdgs",
+ "columnsFrom": [
+ "sdg_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_sdgs_issue_id_sdg_id_pk": {
+ "name": "issue_sdgs_issue_id_sdg_id_pk",
+ "columns": [
+ "issue_id",
+ "sdg_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tags": {
+ "name": "issue_tags",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_tags_issue_id_issues_id_fk": {
+ "name": "issue_tags_issue_id_issues_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tags_tag_id_tags_id_fk": {
+ "name": "issue_tags_tag_id_tags_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_tags_issue_id_tag_id_pk": {
+ "name": "issue_tags_issue_id_tag_id_pk",
+ "columns": [
+ "issue_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issues": {
+ "name": "issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solution_count": {
+ "name": "solution_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sub_issue_count": {
+ "name": "sub_issue_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vote_score": {
+ "name": "vote_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'issue'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "appeal_reason": {
+ "name": "appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appeal_status": {
+ "name": "appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appealed_at": {
+ "name": "appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "solution_status": {
+ "name": "solution_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_request": {
+ "name": "info_request",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_requested_at": {
+ "name": "info_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_response": {
+ "name": "info_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_responded_at": {
+ "name": "info_responded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issues_parent_id_issues_id_fk": {
+ "name": "issues_parent_id_issues_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issues_author_id_users_id_fk": {
+ "name": "issues_author_id_users_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.node_members": {
+ "name": "node_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "node_members_issue_id_idx": {
+ "name": "node_members_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_case_study_id_idx": {
+ "name": "node_members_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_user_id_idx": {
+ "name": "node_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_role_idx": {
+ "name": "node_members_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "node_members_issue_id_issues_id_fk": {
+ "name": "node_members_issue_id_issues_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_case_study_id_case_studies_id_fk": {
+ "name": "node_members_case_study_id_case_studies_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_user_id_users_id_fk": {
+ "name": "node_members_user_id_users_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_clients": {
+ "name": "oauth_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_codes": {
+ "name": "oauth_codes",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge_method": {
+ "name": "code_challenge_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'S256'"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_codes_client_id_oauth_clients_id_fk": {
+ "name": "oauth_codes_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_codes_user_id_users_id_fk": {
+ "name": "oauth_codes_user_id_users_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_tokens": {
+ "name": "oauth_tokens",
+ "schema": "",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_hash": {
+ "name": "refresh_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_tokens_user_idx": {
+ "name": "oauth_tokens_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_refresh_idx": {
+ "name": "oauth_tokens_refresh_idx",
+ "columns": [
+ {
+ "expression": "refresh_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_tokens_client_id_oauth_clients_id_fk": {
+ "name": "oauth_tokens_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_user_id_users_id_fk": {
+ "name": "oauth_tokens_user_id_users_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualification_endorsements": {
+ "name": "qualification_endorsements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "qualification_id": {
+ "name": "qualification_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endorser_id": {
+ "name": "endorser_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'endorsement'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualification_endorsements_endorser_idx": {
+ "name": "qualification_endorsements_endorser_idx",
+ "columns": [
+ {
+ "expression": "endorser_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualification_endorsements_qualification_id_qualifications_id_fk": {
+ "name": "qualification_endorsements_qualification_id_qualifications_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "qualifications",
+ "columnsFrom": [
+ "qualification_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "qualification_endorsements_endorser_id_users_id_fk": {
+ "name": "qualification_endorsements_endorser_id_users_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "users",
+ "columnsFrom": [
+ "endorser_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "qualification_endorsements_unique": {
+ "name": "qualification_endorsements_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "qualification_id",
+ "endorser_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualifications": {
+ "name": "qualifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualifications_user_id_idx": {
+ "name": "qualifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualifications_user_id_users_id_fk": {
+ "name": "qualifications_user_id_users_id_fk",
+ "tableFrom": "qualifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revisions": {
+ "name": "revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposer_id": {
+ "name": "proposer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "changes": {
+ "name": "changes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_snapshot": {
+ "name": "base_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applied_snapshot": {
+ "name": "applied_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_updated_at": {
+ "name": "base_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_verdict": {
+ "name": "ai_verdict",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_confidence": {
+ "name": "ai_confidence",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_reason": {
+ "name": "ai_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_id": {
+ "name": "decided_by_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_role": {
+ "name": "decided_by_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_reason": {
+ "name": "decision_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "revisions_issue_id_idx": {
+ "name": "revisions_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_case_study_id_idx": {
+ "name": "revisions_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_status_idx": {
+ "name": "revisions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_proposer_id_idx": {
+ "name": "revisions_proposer_id_idx",
+ "columns": [
+ {
+ "expression": "proposer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_target_kind_status_idx": {
+ "name": "revisions_target_kind_status_idx",
+ "columns": [
+ {
+ "expression": "target_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revisions_issue_id_issues_id_fk": {
+ "name": "revisions_issue_id_issues_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_case_study_id_case_studies_id_fk": {
+ "name": "revisions_case_study_id_case_studies_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_proposer_id_users_id_fk": {
+ "name": "revisions_proposer_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "proposer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "revisions_decided_by_id_users_id_fk": {
+ "name": "revisions_decided_by_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "decided_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sdgs": {
+ "name": "sdgs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_slug_unique": {
+ "name": "tags_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headline": {
+ "name": "headline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_until": {
+ "name": "banned_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appealed_at": {
+ "name": "ban_appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_status": {
+ "name": "ban_appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_reason": {
+ "name": "ban_appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_score": {
+ "name": "trust_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trust_score_updated_at": {
+ "name": "trust_score_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.votes": {
+ "name": "votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_user_id_users_id_fk": {
+ "name": "votes_user_id_users_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_issue_id_issues_id_fk": {
+ "name": "votes_issue_id_issues_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "votes_user_id_issue_id_unique": {
+ "name": "votes_user_id_issue_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "issue_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/database/migrations/meta/0020_snapshot.json b/server/database/migrations/meta/0020_snapshot.json
new file mode 100644
index 0000000..2fee0a2
--- /dev/null
+++ b/server/database/migrations/meta/0020_snapshot.json
@@ -0,0 +1,2219 @@
+{
+ "id": "76786cfb-019d-4688-800c-971de62e04ad",
+ "prevId": "b887336e-3075-4c43-97dc-94d1d5d08b39",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto_resolved'"
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_type_idx": {
+ "name": "audit_logs_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_status_idx": {
+ "name": "audit_logs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_issue_id_idx": {
+ "name": "audit_logs_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_user_id_idx": {
+ "name": "audit_logs_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_issue_id_issues_id_fk": {
+ "name": "audit_logs_issue_id_issues_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_user_id_users_id_fk": {
+ "name": "audit_logs_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_reviewed_by_users_id_fk": {
+ "name": "audit_logs_reviewed_by_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_studies": {
+ "name": "case_studies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "solution_id": {
+ "name": "solution_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "implementer": {
+ "name": "implementer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metrics": {
+ "name": "metrics",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "funding_source": {
+ "name": "funding_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lessons_learned": {
+ "name": "lessons_learned",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "case_studies_solution_idx": {
+ "name": "case_studies_solution_idx",
+ "columns": [
+ {
+ "expression": "solution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_studies_solution_id_issues_id_fk": {
+ "name": "case_studies_solution_id_issues_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "solution_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_studies_author_id_users_id_fk": {
+ "name": "case_studies_author_id_users_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "credentials_user_id_users_id_fk": {
+ "name": "credentials_user_id_users_id_fk",
+ "tableFrom": "credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_sdgs": {
+ "name": "issue_sdgs",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sdg_id": {
+ "name": "sdg_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_sdgs_issue_id_issues_id_fk": {
+ "name": "issue_sdgs_issue_id_issues_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_sdgs_sdg_id_sdgs_id_fk": {
+ "name": "issue_sdgs_sdg_id_sdgs_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "sdgs",
+ "columnsFrom": [
+ "sdg_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_sdgs_issue_id_sdg_id_pk": {
+ "name": "issue_sdgs_issue_id_sdg_id_pk",
+ "columns": [
+ "issue_id",
+ "sdg_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tags": {
+ "name": "issue_tags",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_tags_issue_id_issues_id_fk": {
+ "name": "issue_tags_issue_id_issues_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tags_tag_id_tags_id_fk": {
+ "name": "issue_tags_tag_id_tags_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_tags_issue_id_tag_id_pk": {
+ "name": "issue_tags_issue_id_tag_id_pk",
+ "columns": [
+ "issue_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issues": {
+ "name": "issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solution_count": {
+ "name": "solution_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sub_issue_count": {
+ "name": "sub_issue_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vote_score": {
+ "name": "vote_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'issue'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "appeal_reason": {
+ "name": "appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appeal_status": {
+ "name": "appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appealed_at": {
+ "name": "appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "solution_status": {
+ "name": "solution_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_request": {
+ "name": "info_request",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_requested_at": {
+ "name": "info_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_response": {
+ "name": "info_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_responded_at": {
+ "name": "info_responded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issues_parent_id_issues_id_fk": {
+ "name": "issues_parent_id_issues_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issues_author_id_users_id_fk": {
+ "name": "issues_author_id_users_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.node_members": {
+ "name": "node_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "node_members_issue_id_idx": {
+ "name": "node_members_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_case_study_id_idx": {
+ "name": "node_members_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_user_id_idx": {
+ "name": "node_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_role_idx": {
+ "name": "node_members_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "node_members_issue_id_issues_id_fk": {
+ "name": "node_members_issue_id_issues_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_case_study_id_case_studies_id_fk": {
+ "name": "node_members_case_study_id_case_studies_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_user_id_users_id_fk": {
+ "name": "node_members_user_id_users_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_clients": {
+ "name": "oauth_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_codes": {
+ "name": "oauth_codes",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge_method": {
+ "name": "code_challenge_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'S256'"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_codes_client_id_oauth_clients_id_fk": {
+ "name": "oauth_codes_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_codes_user_id_users_id_fk": {
+ "name": "oauth_codes_user_id_users_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_tokens": {
+ "name": "oauth_tokens",
+ "schema": "",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_hash": {
+ "name": "refresh_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_tokens_user_idx": {
+ "name": "oauth_tokens_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_refresh_idx": {
+ "name": "oauth_tokens_refresh_idx",
+ "columns": [
+ {
+ "expression": "refresh_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_tokens_client_id_oauth_clients_id_fk": {
+ "name": "oauth_tokens_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_user_id_users_id_fk": {
+ "name": "oauth_tokens_user_id_users_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualification_endorsements": {
+ "name": "qualification_endorsements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "qualification_id": {
+ "name": "qualification_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endorser_id": {
+ "name": "endorser_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'endorsement'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualification_endorsements_endorser_idx": {
+ "name": "qualification_endorsements_endorser_idx",
+ "columns": [
+ {
+ "expression": "endorser_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualification_endorsements_qualification_id_qualifications_id_fk": {
+ "name": "qualification_endorsements_qualification_id_qualifications_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "qualifications",
+ "columnsFrom": [
+ "qualification_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "qualification_endorsements_endorser_id_users_id_fk": {
+ "name": "qualification_endorsements_endorser_id_users_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "users",
+ "columnsFrom": [
+ "endorser_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "qualification_endorsements_unique": {
+ "name": "qualification_endorsements_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "qualification_id",
+ "endorser_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualifications": {
+ "name": "qualifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualifications_user_id_idx": {
+ "name": "qualifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualifications_user_id_users_id_fk": {
+ "name": "qualifications_user_id_users_id_fk",
+ "tableFrom": "qualifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revisions": {
+ "name": "revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposer_id": {
+ "name": "proposer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "changes": {
+ "name": "changes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_snapshot": {
+ "name": "base_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applied_snapshot": {
+ "name": "applied_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_updated_at": {
+ "name": "base_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_verdict": {
+ "name": "ai_verdict",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_confidence": {
+ "name": "ai_confidence",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_reason": {
+ "name": "ai_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_id": {
+ "name": "decided_by_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_role": {
+ "name": "decided_by_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_reason": {
+ "name": "decision_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "revisions_issue_id_idx": {
+ "name": "revisions_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_case_study_id_idx": {
+ "name": "revisions_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_status_idx": {
+ "name": "revisions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_proposer_id_idx": {
+ "name": "revisions_proposer_id_idx",
+ "columns": [
+ {
+ "expression": "proposer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_target_kind_status_idx": {
+ "name": "revisions_target_kind_status_idx",
+ "columns": [
+ {
+ "expression": "target_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revisions_issue_id_issues_id_fk": {
+ "name": "revisions_issue_id_issues_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_case_study_id_case_studies_id_fk": {
+ "name": "revisions_case_study_id_case_studies_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_proposer_id_users_id_fk": {
+ "name": "revisions_proposer_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "proposer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "revisions_decided_by_id_users_id_fk": {
+ "name": "revisions_decided_by_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "decided_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sdgs": {
+ "name": "sdgs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_slug_unique": {
+ "name": "tags_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headline": {
+ "name": "headline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_until": {
+ "name": "banned_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appealed_at": {
+ "name": "ban_appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_status": {
+ "name": "ban_appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_reason": {
+ "name": "ban_appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_score": {
+ "name": "trust_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trust_score_updated_at": {
+ "name": "trust_score_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.votes": {
+ "name": "votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_user_id_users_id_fk": {
+ "name": "votes_user_id_users_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_issue_id_issues_id_fk": {
+ "name": "votes_issue_id_issues_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "votes_user_id_issue_id_unique": {
+ "name": "votes_user_id_issue_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "issue_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wanted_skills": {
+ "name": "wanted_skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill": {
+ "name": "skill",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "wanted_skills_issue_id_idx": {
+ "name": "wanted_skills_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "wanted_skills_issue_id_issues_id_fk": {
+ "name": "wanted_skills_issue_id_issues_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wanted_skills_created_by_users_id_fk": {
+ "name": "wanted_skills_created_by_users_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/database/migrations/meta/0021_snapshot.json b/server/database/migrations/meta/0021_snapshot.json
new file mode 100644
index 0000000..b9e3c8a
--- /dev/null
+++ b/server/database/migrations/meta/0021_snapshot.json
@@ -0,0 +1,2350 @@
+{
+ "id": "a6dcd717-0ad3-4c3c-b8ae-d19ca0fbe47e",
+ "prevId": "76786cfb-019d-4688-800c-971de62e04ad",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto_resolved'"
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_type_idx": {
+ "name": "audit_logs_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_status_idx": {
+ "name": "audit_logs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_issue_id_idx": {
+ "name": "audit_logs_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_user_id_idx": {
+ "name": "audit_logs_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_issue_id_issues_id_fk": {
+ "name": "audit_logs_issue_id_issues_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_user_id_users_id_fk": {
+ "name": "audit_logs_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_reviewed_by_users_id_fk": {
+ "name": "audit_logs_reviewed_by_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_studies": {
+ "name": "case_studies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "solution_id": {
+ "name": "solution_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "implementer": {
+ "name": "implementer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metrics": {
+ "name": "metrics",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "funding_source": {
+ "name": "funding_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lessons_learned": {
+ "name": "lessons_learned",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "case_studies_solution_idx": {
+ "name": "case_studies_solution_idx",
+ "columns": [
+ {
+ "expression": "solution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_studies_solution_id_issues_id_fk": {
+ "name": "case_studies_solution_id_issues_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "solution_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_studies_author_id_users_id_fk": {
+ "name": "case_studies_author_id_users_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "credentials_user_id_users_id_fk": {
+ "name": "credentials_user_id_users_id_fk",
+ "tableFrom": "credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_sdgs": {
+ "name": "issue_sdgs",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sdg_id": {
+ "name": "sdg_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_sdgs_issue_id_issues_id_fk": {
+ "name": "issue_sdgs_issue_id_issues_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_sdgs_sdg_id_sdgs_id_fk": {
+ "name": "issue_sdgs_sdg_id_sdgs_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "sdgs",
+ "columnsFrom": [
+ "sdg_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_sdgs_issue_id_sdg_id_pk": {
+ "name": "issue_sdgs_issue_id_sdg_id_pk",
+ "columns": [
+ "issue_id",
+ "sdg_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tags": {
+ "name": "issue_tags",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_tags_issue_id_issues_id_fk": {
+ "name": "issue_tags_issue_id_issues_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tags_tag_id_tags_id_fk": {
+ "name": "issue_tags_tag_id_tags_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_tags_issue_id_tag_id_pk": {
+ "name": "issue_tags_issue_id_tag_id_pk",
+ "columns": [
+ "issue_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issues": {
+ "name": "issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solution_count": {
+ "name": "solution_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sub_issue_count": {
+ "name": "sub_issue_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vote_score": {
+ "name": "vote_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'issue'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "appeal_reason": {
+ "name": "appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appeal_status": {
+ "name": "appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appealed_at": {
+ "name": "appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "solution_status": {
+ "name": "solution_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_request": {
+ "name": "info_request",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_requested_at": {
+ "name": "info_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_response": {
+ "name": "info_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_responded_at": {
+ "name": "info_responded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issues_parent_id_issues_id_fk": {
+ "name": "issues_parent_id_issues_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issues_author_id_users_id_fk": {
+ "name": "issues_author_id_users_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.newsletter_prefs": {
+ "name": "newsletter_prefs",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "frequency": {
+ "name": "frequency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'monthly'"
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "newsletter_prefs_user_id_users_id_fk": {
+ "name": "newsletter_prefs_user_id_users_id_fk",
+ "tableFrom": "newsletter_prefs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.node_members": {
+ "name": "node_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "node_members_issue_id_idx": {
+ "name": "node_members_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_case_study_id_idx": {
+ "name": "node_members_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_user_id_idx": {
+ "name": "node_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_role_idx": {
+ "name": "node_members_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "node_members_issue_id_issues_id_fk": {
+ "name": "node_members_issue_id_issues_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_case_study_id_case_studies_id_fk": {
+ "name": "node_members_case_study_id_case_studies_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_user_id_users_id_fk": {
+ "name": "node_members_user_id_users_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_clients": {
+ "name": "oauth_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_codes": {
+ "name": "oauth_codes",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge_method": {
+ "name": "code_challenge_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'S256'"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_codes_client_id_oauth_clients_id_fk": {
+ "name": "oauth_codes_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_codes_user_id_users_id_fk": {
+ "name": "oauth_codes_user_id_users_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_tokens": {
+ "name": "oauth_tokens",
+ "schema": "",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_hash": {
+ "name": "refresh_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_tokens_user_idx": {
+ "name": "oauth_tokens_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_refresh_idx": {
+ "name": "oauth_tokens_refresh_idx",
+ "columns": [
+ {
+ "expression": "refresh_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_tokens_client_id_oauth_clients_id_fk": {
+ "name": "oauth_tokens_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_user_id_users_id_fk": {
+ "name": "oauth_tokens_user_id_users_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualification_endorsements": {
+ "name": "qualification_endorsements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "qualification_id": {
+ "name": "qualification_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endorser_id": {
+ "name": "endorser_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'endorsement'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualification_endorsements_endorser_idx": {
+ "name": "qualification_endorsements_endorser_idx",
+ "columns": [
+ {
+ "expression": "endorser_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualification_endorsements_qualification_id_qualifications_id_fk": {
+ "name": "qualification_endorsements_qualification_id_qualifications_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "qualifications",
+ "columnsFrom": [
+ "qualification_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "qualification_endorsements_endorser_id_users_id_fk": {
+ "name": "qualification_endorsements_endorser_id_users_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "users",
+ "columnsFrom": [
+ "endorser_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "qualification_endorsements_unique": {
+ "name": "qualification_endorsements_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "qualification_id",
+ "endorser_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualifications": {
+ "name": "qualifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualifications_user_id_idx": {
+ "name": "qualifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualifications_user_id_users_id_fk": {
+ "name": "qualifications_user_id_users_id_fk",
+ "tableFrom": "qualifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revisions": {
+ "name": "revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposer_id": {
+ "name": "proposer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "changes": {
+ "name": "changes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_snapshot": {
+ "name": "base_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applied_snapshot": {
+ "name": "applied_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_updated_at": {
+ "name": "base_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_verdict": {
+ "name": "ai_verdict",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_confidence": {
+ "name": "ai_confidence",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_reason": {
+ "name": "ai_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_id": {
+ "name": "decided_by_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_role": {
+ "name": "decided_by_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_reason": {
+ "name": "decision_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "revisions_issue_id_idx": {
+ "name": "revisions_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_case_study_id_idx": {
+ "name": "revisions_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_status_idx": {
+ "name": "revisions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_proposer_id_idx": {
+ "name": "revisions_proposer_id_idx",
+ "columns": [
+ {
+ "expression": "proposer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_target_kind_status_idx": {
+ "name": "revisions_target_kind_status_idx",
+ "columns": [
+ {
+ "expression": "target_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revisions_issue_id_issues_id_fk": {
+ "name": "revisions_issue_id_issues_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_case_study_id_case_studies_id_fk": {
+ "name": "revisions_case_study_id_case_studies_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_proposer_id_users_id_fk": {
+ "name": "revisions_proposer_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "proposer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "revisions_decided_by_id_users_id_fk": {
+ "name": "revisions_decided_by_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "decided_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sdgs": {
+ "name": "sdgs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_slug_unique": {
+ "name": "tags_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_interests": {
+ "name": "user_interests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_interests_user_id_users_id_fk": {
+ "name": "user_interests_user_id_users_id_fk",
+ "tableFrom": "user_interests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headline": {
+ "name": "headline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_until": {
+ "name": "banned_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appealed_at": {
+ "name": "ban_appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_status": {
+ "name": "ban_appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_reason": {
+ "name": "ban_appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_score": {
+ "name": "trust_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trust_score_updated_at": {
+ "name": "trust_score_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "onboarded_at": {
+ "name": "onboarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.votes": {
+ "name": "votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_user_id_users_id_fk": {
+ "name": "votes_user_id_users_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_issue_id_issues_id_fk": {
+ "name": "votes_issue_id_issues_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "votes_user_id_issue_id_unique": {
+ "name": "votes_user_id_issue_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "issue_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wanted_skills": {
+ "name": "wanted_skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill": {
+ "name": "skill",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "wanted_skills_issue_id_idx": {
+ "name": "wanted_skills_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "wanted_skills_issue_id_issues_id_fk": {
+ "name": "wanted_skills_issue_id_issues_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wanted_skills_created_by_users_id_fk": {
+ "name": "wanted_skills_created_by_users_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/database/migrations/meta/0022_snapshot.json b/server/database/migrations/meta/0022_snapshot.json
new file mode 100644
index 0000000..0baa520
--- /dev/null
+++ b/server/database/migrations/meta/0022_snapshot.json
@@ -0,0 +1,2356 @@
+{
+ "id": "ff27261e-aab9-408d-b3f1-b7e2606fbf63",
+ "prevId": "a6dcd717-0ad3-4c3c-b8ae-d19ca0fbe47e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto_resolved'"
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_type_idx": {
+ "name": "audit_logs_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_status_idx": {
+ "name": "audit_logs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_issue_id_idx": {
+ "name": "audit_logs_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_user_id_idx": {
+ "name": "audit_logs_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_issue_id_issues_id_fk": {
+ "name": "audit_logs_issue_id_issues_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_user_id_users_id_fk": {
+ "name": "audit_logs_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_reviewed_by_users_id_fk": {
+ "name": "audit_logs_reviewed_by_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_studies": {
+ "name": "case_studies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "solution_id": {
+ "name": "solution_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "implementer": {
+ "name": "implementer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metrics": {
+ "name": "metrics",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost": {
+ "name": "cost",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "funding_source": {
+ "name": "funding_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lessons_learned": {
+ "name": "lessons_learned",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "case_studies_solution_idx": {
+ "name": "case_studies_solution_idx",
+ "columns": [
+ {
+ "expression": "solution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_studies_solution_id_issues_id_fk": {
+ "name": "case_studies_solution_id_issues_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "solution_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_studies_author_id_users_id_fk": {
+ "name": "case_studies_author_id_users_id_fk",
+ "tableFrom": "case_studies",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "credentials_user_id_users_id_fk": {
+ "name": "credentials_user_id_users_id_fk",
+ "tableFrom": "credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_sdgs": {
+ "name": "issue_sdgs",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sdg_id": {
+ "name": "sdg_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_sdgs_issue_id_issues_id_fk": {
+ "name": "issue_sdgs_issue_id_issues_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_sdgs_sdg_id_sdgs_id_fk": {
+ "name": "issue_sdgs_sdg_id_sdgs_id_fk",
+ "tableFrom": "issue_sdgs",
+ "tableTo": "sdgs",
+ "columnsFrom": [
+ "sdg_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_sdgs_issue_id_sdg_id_pk": {
+ "name": "issue_sdgs_issue_id_sdg_id_pk",
+ "columns": [
+ "issue_id",
+ "sdg_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tags": {
+ "name": "issue_tags",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issue_tags_issue_id_issues_id_fk": {
+ "name": "issue_tags_issue_id_issues_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tags_tag_id_tags_id_fk": {
+ "name": "issue_tags_tag_id_tags_id_fk",
+ "tableFrom": "issue_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_tags_issue_id_tag_id_pk": {
+ "name": "issue_tags_issue_id_tag_id_pk",
+ "columns": [
+ "issue_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issues": {
+ "name": "issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "solution_count": {
+ "name": "solution_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sub_issue_count": {
+ "name": "sub_issue_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vote_score": {
+ "name": "vote_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'issue'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_spam": {
+ "name": "is_spam",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "appeal_reason": {
+ "name": "appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appeal_status": {
+ "name": "appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appealed_at": {
+ "name": "appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location_name": {
+ "name": "location_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "geometry(point)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scale": {
+ "name": "scale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "area": {
+ "name": "area",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "help_labels": {
+ "name": "help_labels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "solution_status": {
+ "name": "solution_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links": {
+ "name": "links",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_request": {
+ "name": "info_request",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_requested_at": {
+ "name": "info_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_response": {
+ "name": "info_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "info_responded_at": {
+ "name": "info_responded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "issues_parent_id_issues_id_fk": {
+ "name": "issues_parent_id_issues_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issues_author_id_users_id_fk": {
+ "name": "issues_author_id_users_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.newsletter_prefs": {
+ "name": "newsletter_prefs",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "frequency": {
+ "name": "frequency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'monthly'"
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "newsletter_prefs_user_id_users_id_fk": {
+ "name": "newsletter_prefs_user_id_users_id_fk",
+ "tableFrom": "newsletter_prefs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.node_members": {
+ "name": "node_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "node_members_issue_id_idx": {
+ "name": "node_members_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_case_study_id_idx": {
+ "name": "node_members_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_user_id_idx": {
+ "name": "node_members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "node_members_role_idx": {
+ "name": "node_members_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "node_members_issue_id_issues_id_fk": {
+ "name": "node_members_issue_id_issues_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_case_study_id_case_studies_id_fk": {
+ "name": "node_members_case_study_id_case_studies_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "node_members_user_id_users_id_fk": {
+ "name": "node_members_user_id_users_id_fk",
+ "tableFrom": "node_members",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_clients": {
+ "name": "oauth_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_codes": {
+ "name": "oauth_codes",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge_method": {
+ "name": "code_challenge_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'S256'"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oauth_codes_client_id_oauth_clients_id_fk": {
+ "name": "oauth_codes_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_codes_user_id_users_id_fk": {
+ "name": "oauth_codes_user_id_users_id_fk",
+ "tableFrom": "oauth_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_tokens": {
+ "name": "oauth_tokens",
+ "schema": "",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_hash": {
+ "name": "refresh_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_tokens_user_idx": {
+ "name": "oauth_tokens_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_refresh_idx": {
+ "name": "oauth_tokens_refresh_idx",
+ "columns": [
+ {
+ "expression": "refresh_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_tokens_client_id_oauth_clients_id_fk": {
+ "name": "oauth_tokens_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_user_id_users_id_fk": {
+ "name": "oauth_tokens_user_id_users_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualification_endorsements": {
+ "name": "qualification_endorsements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "qualification_id": {
+ "name": "qualification_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endorser_id": {
+ "name": "endorser_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'endorsement'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualification_endorsements_endorser_idx": {
+ "name": "qualification_endorsements_endorser_idx",
+ "columns": [
+ {
+ "expression": "endorser_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualification_endorsements_qualification_id_qualifications_id_fk": {
+ "name": "qualification_endorsements_qualification_id_qualifications_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "qualifications",
+ "columnsFrom": [
+ "qualification_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "qualification_endorsements_endorser_id_users_id_fk": {
+ "name": "qualification_endorsements_endorser_id_users_id_fk",
+ "tableFrom": "qualification_endorsements",
+ "tableTo": "users",
+ "columnsFrom": [
+ "endorser_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "qualification_endorsements_unique": {
+ "name": "qualification_endorsements_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "qualification_id",
+ "endorser_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.qualifications": {
+ "name": "qualifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area": {
+ "name": "area",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "qualifications_user_id_idx": {
+ "name": "qualifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "qualifications_user_id_users_id_fk": {
+ "name": "qualifications_user_id_users_id_fk",
+ "tableFrom": "qualifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revisions": {
+ "name": "revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_study_id": {
+ "name": "case_study_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposer_id": {
+ "name": "proposer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "changes": {
+ "name": "changes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_snapshot": {
+ "name": "base_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applied_snapshot": {
+ "name": "applied_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_updated_at": {
+ "name": "base_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_verdict": {
+ "name": "ai_verdict",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_confidence": {
+ "name": "ai_confidence",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_reason": {
+ "name": "ai_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_id": {
+ "name": "decided_by_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_role": {
+ "name": "decided_by_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_reason": {
+ "name": "decision_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "revisions_issue_id_idx": {
+ "name": "revisions_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_case_study_id_idx": {
+ "name": "revisions_case_study_id_idx",
+ "columns": [
+ {
+ "expression": "case_study_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_status_idx": {
+ "name": "revisions_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_proposer_id_idx": {
+ "name": "revisions_proposer_id_idx",
+ "columns": [
+ {
+ "expression": "proposer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revisions_target_kind_status_idx": {
+ "name": "revisions_target_kind_status_idx",
+ "columns": [
+ {
+ "expression": "target_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revisions_issue_id_issues_id_fk": {
+ "name": "revisions_issue_id_issues_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_case_study_id_case_studies_id_fk": {
+ "name": "revisions_case_study_id_case_studies_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "case_studies",
+ "columnsFrom": [
+ "case_study_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revisions_proposer_id_users_id_fk": {
+ "name": "revisions_proposer_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "proposer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "revisions_decided_by_id_users_id_fk": {
+ "name": "revisions_decided_by_id_users_id_fk",
+ "tableFrom": "revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "decided_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sdgs": {
+ "name": "sdgs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_slug_unique": {
+ "name": "tags_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_interests": {
+ "name": "user_interests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_interests_user_id_users_id_fk": {
+ "name": "user_interests_user_id_users_id_fk",
+ "tableFrom": "user_interests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headline": {
+ "name": "headline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned_until": {
+ "name": "banned_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appealed_at": {
+ "name": "ban_appealed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_status": {
+ "name": "ban_appeal_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_appeal_reason": {
+ "name": "ban_appeal_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_score": {
+ "name": "trust_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trust_score_updated_at": {
+ "name": "trust_score_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "onboarded_at": {
+ "name": "onboarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.votes": {
+ "name": "votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_user_id_users_id_fk": {
+ "name": "votes_user_id_users_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_issue_id_issues_id_fk": {
+ "name": "votes_issue_id_issues_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "votes_user_id_issue_id_unique": {
+ "name": "votes_user_id_issue_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "issue_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wanted_skills": {
+ "name": "wanted_skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill": {
+ "name": "skill",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "wanted_skills_issue_id_idx": {
+ "name": "wanted_skills_issue_id_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "wanted_skills_issue_id_issues_id_fk": {
+ "name": "wanted_skills_issue_id_issues_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wanted_skills_created_by_users_id_fk": {
+ "name": "wanted_skills_created_by_users_id_fk",
+ "tableFrom": "wanted_skills",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json
index 90ea29b..39eb567 100644
--- a/server/database/migrations/meta/_journal.json
+++ b/server/database/migrations/meta/_journal.json
@@ -134,6 +134,34 @@
"when": 1782810102360,
"tag": "0018_resource_audience_binding",
"breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "7",
+ "when": 1783923614093,
+ "tag": "0019_boring_menace",
+ "breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "7",
+ "when": 1783931181576,
+ "tag": "0020_smart_blur",
+ "breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "7",
+ "when": 1783931791406,
+ "tag": "0021_vengeful_whizzer",
+ "breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "7",
+ "when": 1783938297166,
+ "tag": "0022_material_tony_stark",
+ "breakpoints": true
}
]
}
diff --git a/server/database/schema.ts b/server/database/schema.ts
index 7860477..e29e5be 100644
--- a/server/database/schema.ts
+++ b/server/database/schema.ts
@@ -12,10 +12,17 @@ import {
index,
numeric,
date,
+ vector,
+ geometry,
} from 'drizzle-orm/pg-core'
-import { vector } from 'drizzle-orm/pg-core'
-import { geometry } from 'drizzle-orm/pg-core'
-import { relations } from 'drizzle-orm'
+import { relations, sql } from 'drizzle-orm'
+
+// Standard audit pair, spread into every table that tracks both. Drizzle
+// builds a fresh column per table from the shared builders, so reuse is safe.
+const timestamps = {
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+}
export const PROVIDERS = ['google', 'apple', 'passkey'] as const
export type Provider = (typeof PROVIDERS)[number]
@@ -51,6 +58,20 @@ export const CASE_STUDY_OUTCOMES = [
] as const
export type CaseStudyOutcome = (typeof CASE_STUDY_OUTCOMES)[number]
+// System-managed "help wanted" labels the moderation pipeline attaches to a node
+// when it has a structural evidence/quality gap. They are NOT shown on the node
+// itself — they surface only on the contribute view, where users filter by them
+// to find nodes worth improving. Distinct from topic tags (which describe what a
+// node is about); help labels describe what a node still needs.
+export const HELP_LABELS = [
+ 'needs-evidence',
+ 'needs-baseline',
+ 'needs-sources',
+ 'needs-cost',
+ 'needs-location',
+] as const
+export type HelpLabel = (typeof HELP_LABELS)[number]
+
export const AUDIT_LOG_TYPES = [
'moderation',
'structure',
@@ -81,6 +102,7 @@ export const AUDIT_LOG_ACTIONS = [
'remod',
'relocate',
'curate',
+ 'label',
// Collaborative revisions: a user proposes an edit, the owner/admin
// accepts/rejects, or the proposer withdraws it.
'propose',
@@ -148,8 +170,10 @@ export const users = pgTable('users', {
banAppealReason: text('ban_appeal_reason'),
trustScore: integer('trust_score').notNull().default(0),
trustScoreUpdatedAt: timestamp('trust_score_updated_at', { withTimezone: true }),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ // When the user completed (or skipped) the onboarding flow. Null = the
+ // onboarding page has not been seen yet; post-login redirects send them there.
+ onboardedAt: timestamp('onboarded_at', { withTimezone: true }),
+ ...timestamps,
})
export const usersRelations = relations(users, ({ many }) => ({
@@ -158,6 +182,62 @@ export const usersRelations = relations(users, ({ many }) => ({
votes: many(votes),
qualifications: many(qualifications),
endorsementsGiven: many(qualificationEndorsements),
+ interests: many(userInterests),
+}))
+
+// Free-text topics a user cares about, deliberately NOT a foreign key to tags:
+// users can declare interests the catalog does not cover yet ("beekeeping",
+// "youth mental health"). Matching happens semantically via the embedding, so
+// the vector is the meaning, not a tag id. Uniqueness per user is
+// case-insensitive via a unique index on (user_id, lower(label)) in custom
+// migration 0008, which also adds the HNSW index on the embedding.
+export const userInterests = pgTable('user_interests', {
+ id: serial('id').primaryKey(),
+ userId: uuid('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ // Display case as typed, e.g. "Youth mental health".
+ label: text('label').notNull(),
+ // Null when the embedding call failed at insert time; adding an interest
+ // must never block on OpenAI availability.
+ embedding: vector('embedding', { dimensions: 1536 }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+})
+
+export const userInterestsRelations = relations(userInterests, ({ one }) => ({
+ user: one(users, { fields: [userInterests.userId], references: [users.id] }),
+}))
+
+export const NEWSLETTER_FREQUENCIES = ['weekly', 'monthly'] as const
+export type NewsletterFrequency = (typeof NEWSLETTER_FREQUENCIES)[number]
+
+// Which content blocks the user wants in their newsletter.
+export interface NewsletterContent {
+ goodNews: boolean
+ skillMatches: boolean
+ topicMatches: boolean
+ helpWanted: boolean
+ productUpdates: boolean
+}
+
+// Newsletter consent + preferences. One row per user, upserted from
+// onboarding and settings. The sending side (digest assembly, templates,
+// cron tasks) lives in server/utils/newsletter-*.ts + server/tasks/newsletter/.
+export const newsletterPrefs = pgTable('newsletter_prefs', {
+ userId: uuid('user_id')
+ .primaryKey()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ enabled: boolean('enabled').notNull().default(false),
+ frequency: text('frequency').notNull().default('monthly').$type(),
+ content: jsonb('content').$type(),
+ // Set after each successful digest send; guards cron retries/overlaps from
+ // double-sending (see MIN_RESEND_DAYS in server/utils/newsletter-send.ts).
+ lastSentAt: timestamp('last_sent_at', { withTimezone: true }),
+ ...timestamps,
+})
+
+export const newsletterPrefsRelations = relations(newsletterPrefs, ({ one }) => ({
+ user: one(users, { fields: [newsletterPrefs.userId], references: [users.id] }),
}))
export const credentials = pgTable('credentials', {
@@ -169,8 +249,7 @@ export const credentials = pgTable('credentials', {
counter: integer('counter').notNull().default(0),
backedUp: boolean('backed_up').notNull().default(false),
transports: jsonb('transports').$type().notNull().default([]),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
})
export const credentialsRelations = relations(credentials, ({ one }) => ({
@@ -182,8 +261,7 @@ export const sdgs = pgTable('sdgs', {
name: text('name').notNull(),
iconUrl: text('icon_url').notNull(),
link: text('link').notNull(),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
})
export const tags = pgTable('tags', {
@@ -191,8 +269,7 @@ export const tags = pgTable('tags', {
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
embedding: vector('embedding', { dimensions: 1536 }),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
})
export const issues = pgTable('issues', {
@@ -218,6 +295,13 @@ export const issues = pgTable('issues', {
scale: text('scale').$type(),
// GeoJSON area for the location; `location` above is the centroid.
area: jsonb('area').$type(),
+ // System-managed help-wanted labels (see HELP_LABELS). Written only by the
+ // moderation pipeline; surfaced on the contribute view, never on the node card.
+ helpLabels: text('help_labels')
+ .array()
+ .$type()
+ .notNull()
+ .default(sql`'{}'::text[]`),
// Only meaningful when type='solution'.
solutionStatus: text('solution_status').$type(),
// External resources — only surfaced for solutions.
@@ -227,8 +311,7 @@ export const issues = pgTable('issues', {
infoResponse: text('info_response'),
infoRespondedAt: timestamp('info_responded_at', { withTimezone: true }),
embedding: vector('embedding', { dimensions: 1536 }),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
})
export const issuesRelations = relations(issues, ({ one, many }) => ({
@@ -243,6 +326,7 @@ export const issuesRelations = relations(issues, ({ one, many }) => ({
issueSdgs: many(issueSdgs),
votes: many(votes),
caseStudies: many(caseStudies),
+ wantedSkills: many(wantedSkills),
}))
export const votes = pgTable(
@@ -257,8 +341,7 @@ export const votes = pgTable(
.references(() => issues.id, { onDelete: 'cascade' }),
value: integer('value').notNull(), // +1 or -1
weight: integer('weight').notNull().default(1), // derived from voter's trust score
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
},
(t) => [unique().on(t.userId, t.issueId)],
)
@@ -321,8 +404,7 @@ export const qualifications = pgTable(
area: text('area').notNull(),
// Optional longer context — how, where, when, proof links.
detail: text('detail'),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
},
(t) => [index('qualifications_user_id_idx').on(t.userId)],
)
@@ -378,6 +460,30 @@ export const qualificationEndorsementsRelations = relations(
}),
)
+// Demand side of skill matching: skills a node (issue/solution) is looking
+// for. The supply side is `qualifications` above (what users can do). Any
+// logged-in user can add one; uniqueness per node is case-insensitive via a
+// unique index on (issue_id, lower(skill)) in custom migration 0007.
+export const wantedSkills = pgTable(
+ 'wanted_skills',
+ {
+ id: serial('id').primaryKey(),
+ issueId: integer('issue_id')
+ .notNull()
+ .references(() => issues.id, { onDelete: 'cascade' }),
+ // Display case as typed, e.g. "Structural engineering".
+ skill: text('skill').notNull(),
+ createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [index('wanted_skills_issue_id_idx').on(t.issueId)],
+)
+
+export const wantedSkillsRelations = relations(wantedSkills, ({ one }) => ({
+ issue: one(issues, { fields: [wantedSkills.issueId], references: [issues.id] }),
+ creator: one(users, { fields: [wantedSkills.createdBy], references: [users.id] }),
+}))
+
export const oauthClients = pgTable('oauth_clients', {
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -435,6 +541,13 @@ export const caseStudies = pgTable(
area: jsonb('area').$type(),
// Admin-set: lets us mark a case study as independently verified.
verified: boolean('verified').notNull().default(false),
+ // System-managed help-wanted labels (see HELP_LABELS). Written only by the
+ // moderation pipeline; surfaced on the contribute view, never on the card.
+ helpLabels: text('help_labels')
+ .array()
+ .$type()
+ .notNull()
+ .default(sql`'{}'::text[]`),
implementer: text('implementer'),
startDate: date('start_date', { mode: 'string' }),
endDate: date('end_date', { mode: 'string' }),
@@ -453,8 +566,7 @@ export const caseStudies = pgTable(
// which is reserved for citations backing the claims.
links: jsonb('links').$type>(),
embedding: vector('embedding', { dimensions: 1536 }),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
},
(t) => [index('case_studies_solution_idx').on(t.solutionId)],
)
@@ -559,8 +671,7 @@ export const revisions = pgTable(
decidedByRole: text('decided_by_role').$type(),
decisionReason: text('decision_reason'),
decidedAt: timestamp('decided_at', { withTimezone: true }),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
},
(t) => [
index('revisions_issue_id_idx').on(t.issueId),
@@ -605,8 +716,7 @@ export const nodeMembers = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull().$type(),
source: text('source').$type(),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+ ...timestamps,
},
(t) => [
index('node_members_issue_id_idx').on(t.issueId),
diff --git a/server/plugins/hyperdrive.ts b/server/plugins/hyperdrive.ts
index d252c95..5597d0b 100644
--- a/server/plugins/hyperdrive.ts
+++ b/server/plugins/hyperdrive.ts
@@ -4,6 +4,7 @@
//
// Locally and in CI, `event.context.cloudflare` is undefined and this is a
// no-op — the URL comes from the NUXT_DATABASE_URL env var instead.
+import { setScheduledEmailBinding } from '../utils/email-binding'
function hydrateFromBinding(env: Record | undefined) {
const connectionString = env?.HYPERDRIVE?.connectionString
@@ -20,7 +21,10 @@ export default defineNitroPlugin((nitroApp) => {
// Scheduled tasks (cron triggers) — the cloudflare preset fires this
// before invoking `runCronTasks`, giving us access to the env bindings.
+ // Tasks have no h3 event, so the EMAIL binding is stashed for `sendEmail`
+ // to pick up (newsletter sends) alongside the DB URL hydration.
nitroApp.hooks.hook('cloudflare:scheduled' as any, ({ env }: { env: Record }) => {
hydrateFromBinding(env)
+ setScheduledEmailBinding(env?.EMAIL)
})
})
diff --git a/server/routes/.well-known/mcp/server-card.json.get.ts b/server/routes/.well-known/mcp/server-card.json.get.ts
index 4ede2bd..b151327 100644
--- a/server/routes/.well-known/mcp/server-card.json.get.ts
+++ b/server/routes/.well-known/mcp/server-card.json.get.ts
@@ -51,13 +51,22 @@ export default defineEventHandler((event) => {
{ name: 'get_tree', description: 'Return the full descendant tree of an issue or solution.' },
{ name: 'create_issue', description: 'Create a new issue (top-level or sub-issue).' },
{ name: 'create_solution', description: 'Propose a solution for an existing issue.' },
- { name: 'update_issue', description: 'Edit an issue you authored.' },
- { name: 'update_solution', description: 'Edit a solution you authored.' },
+ {
+ name: 'update_issue',
+ description: 'Edit an issue (applies live if you own it, else queued as a proposal).',
+ },
+ {
+ name: 'update_solution',
+ description: 'Edit a solution (applies live if you own it, else queued as a proposal).',
+ },
{
name: 'create_case_study',
description: 'Document a real-world implementation of a solution.',
},
- { name: 'update_case_study', description: 'Edit a case study you authored.' },
+ {
+ name: 'update_case_study',
+ description: 'Edit a case study (applies live if you own it, else queued as a proposal).',
+ },
{ name: 'get_case_study', description: 'Fetch a single case study by id.' },
{ name: 'list_case_studies', description: 'List case studies for a given solution.' },
{ name: 'suggest_more', description: 'Get semantically similar issues or solutions.' },
@@ -70,7 +79,20 @@ export default defineEventHandler((event) => {
},
{
name: 'get_guide',
- description: 'Read authoring guides for writing good issues, solutions, and case studies.',
+ description:
+ 'Read the authoring guide for writing good issues, solutions, and case studies.',
+ },
+ {
+ name: 'propose_edit',
+ description: 'Propose a change to any issue, solution, or case study in one tool.',
+ },
+ {
+ name: 'list_revisions',
+ description: 'List a node revision history and pending proposals.',
+ },
+ {
+ name: 'review_revision',
+ description: 'Approve or reject a pending revision proposal on a node you own.',
},
],
contact: {
diff --git a/server/routes/auth/google.get.ts b/server/routes/auth/google.get.ts
index 015e970..5ee1591 100644
--- a/server/routes/auth/google.get.ts
+++ b/server/routes/auth/google.get.ts
@@ -2,8 +2,11 @@ export default defineOAuthGoogleEventHandler({
async onSuccess(event, { user }) {
try {
if (!user.email) throw createError({ statusCode: 400, message: 'Email is required' })
- const { isNew } = await handleOAuthLogin(event, user.email, user.name, 'google')
- return sendRedirect(event, consumePostLoginRedirect(event) ?? (isNew ? '/settings' : '/'))
+ const { user: dbUser } = await handleOAuthLogin(event, user.email, user.name, 'google')
+ // Explicit redirect targets (auth gates, MCP OAuth resume) always win;
+ // otherwise users who haven't been through onboarding land there.
+ const target = consumePostLoginRedirect(event)
+ return sendRedirect(event, target ?? (dbUser.onboardedAt ? '/' : '/onboarding'))
} catch (err) {
console.error('Google OAuth onSuccess error:', err)
return sendRedirect(event, '/login?error=google')
diff --git a/server/routes/oauth/authorize.get.ts b/server/routes/oauth/authorize.get.ts
index 9001cc5..308807a 100644
--- a/server/routes/oauth/authorize.get.ts
+++ b/server/routes/oauth/authorize.get.ts
@@ -1,4 +1,5 @@
import { getClient, issueConsentToken, mcpResource, OAUTH_SCOPE } from '../../utils/oauth'
+import { escapeHtml } from '../../utils/escape-html'
const POST_LOGIN_COOKIE = 'mcp_continue'
@@ -15,13 +16,6 @@ function redirectError(
return u.toString()
}
-function escapeHtml(s: string) {
- return s.replace(
- /[&<>"']/g,
- (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
- )
-}
-
export default defineEventHandler(async (event) => {
const q = getQuery(event)
const clientId = typeof q.client_id === 'string' ? q.client_id : ''
diff --git a/server/routes/openapi.json.get.ts b/server/routes/openapi.json.get.ts
index 54ee301..d3d6252 100644
--- a/server/routes/openapi.json.get.ts
+++ b/server/routes/openapi.json.get.ts
@@ -247,7 +247,21 @@ export default defineEventHandler((event) => {
description: 'Matching case studies.',
content: {
'application/json': {
- schema: { type: 'array', items: { $ref: '#/components/schemas/CaseStudy' } },
+ schema: {
+ type: 'object',
+ properties: {
+ items: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/CaseStudy' },
+ },
+ degraded: {
+ type: 'boolean',
+ description:
+ 'True when a semantic `query` was supplied but embeddings were unavailable, so `items` is a recency-ordered fallback rather than a similarity ranking.',
+ },
+ },
+ required: ['items', 'degraded'],
+ },
},
},
},
diff --git a/server/routes/sitemap.xml.get.ts b/server/routes/sitemap.xml.get.ts
index d466457..95eba85 100644
--- a/server/routes/sitemap.xml.get.ts
+++ b/server/routes/sitemap.xml.get.ts
@@ -6,9 +6,14 @@ const MAX_URLS = 45_000
const STATIC_ROUTES: Array<{ loc: string; changefreq: string; priority: string }> = [
{ loc: '/', changefreq: 'daily', priority: '1.0' },
+ { loc: '/issues', changefreq: 'daily', priority: '0.9' },
+ { loc: '/solutions', changefreq: 'daily', priority: '0.9' },
+ { loc: '/case-studies', changefreq: 'daily', priority: '0.9' },
+ { loc: '/map', changefreq: 'weekly', priority: '0.6' },
+ { loc: '/tags', changefreq: 'weekly', priority: '0.6' },
{ loc: '/whitepaper', changefreq: 'monthly', priority: '0.8' },
{ loc: '/guides', changefreq: 'weekly', priority: '0.7' },
- { loc: '/guide/writing', changefreq: 'monthly', priority: '0.6' },
+ { loc: '/guide/authoring', changefreq: 'monthly', priority: '0.6' },
{ loc: '/privacy', changefreq: 'yearly', priority: '0.3' },
{ loc: '/terms', changefreq: 'yearly', priority: '0.3' },
]
diff --git a/server/tasks/newsletter/monthly.ts b/server/tasks/newsletter/monthly.ts
new file mode 100644
index 0000000..2b76ed0
--- /dev/null
+++ b/server/tasks/newsletter/monthly.ts
@@ -0,0 +1,19 @@
+import { runNewsletterSend } from '../../utils/newsletter-send'
+
+export default defineTask({
+ meta: {
+ name: 'newsletter:monthly',
+ description: 'Send the monthly newsletter digest to opted-in users',
+ },
+ async run() {
+ // Scheduled tasks run without an h3 event — scope one postgres client
+ // across every useDB() call in the run (see compute:trust-scores).
+ return withScopedDB(async () => {
+ const summary = await runNewsletterSend('monthly')
+ return {
+ result: `Monthly newsletter: ${summary.sent} sent, ${summary.empty} empty, ${summary.failures.length} failures (${summary.recipients} recipients)`,
+ ...summary,
+ }
+ })
+ },
+})
diff --git a/server/tasks/newsletter/weekly.ts b/server/tasks/newsletter/weekly.ts
new file mode 100644
index 0000000..ec252f2
--- /dev/null
+++ b/server/tasks/newsletter/weekly.ts
@@ -0,0 +1,19 @@
+import { runNewsletterSend } from '../../utils/newsletter-send'
+
+export default defineTask({
+ meta: {
+ name: 'newsletter:weekly',
+ description: 'Send the weekly newsletter digest to opted-in users',
+ },
+ async run() {
+ // Scheduled tasks run without an h3 event — scope one postgres client
+ // across every useDB() call in the run (see compute:trust-scores).
+ return withScopedDB(async () => {
+ const summary = await runNewsletterSend('weekly')
+ return {
+ result: `Weekly newsletter: ${summary.sent} sent, ${summary.empty} empty, ${summary.failures.length} failures (${summary.recipients} recipients)`,
+ ...summary,
+ }
+ })
+ },
+})
diff --git a/server/utils/auth-store.ts b/server/utils/auth-store.ts
index e06a232..8953d92 100644
--- a/server/utils/auth-store.ts
+++ b/server/utils/auth-store.ts
@@ -121,5 +121,5 @@ export async function handleOAuthLogin(
loggedInAt: Date.now(),
})
- return { isNew: !existing }
+ return { isNew: !existing, user: dbUser }
}
diff --git a/server/utils/case-study-write.ts b/server/utils/case-study-write.ts
index 6b3df69..768596b 100644
--- a/server/utils/case-study-write.ts
+++ b/server/utils/case-study-write.ts
@@ -313,6 +313,7 @@ export function transformCaseStudy(
}
: null,
verified: row.verified,
+ helpLabels: row.helpLabels ?? [],
description: row.description,
implementer: row.implementer,
startDate: row.startDate,
diff --git a/server/utils/discovery.ts b/server/utils/discovery.ts
index 925faa0..499ada8 100644
--- a/server/utils/discovery.ts
+++ b/server/utils/discovery.ts
@@ -9,8 +9,8 @@
// hydration are each their own helper.
import { and, asc, desc, eq, inArray, sql, type SQL } from 'drizzle-orm'
import { caseStudies, issueSdgs, issueTags, issues, tags as tagsTable } from '../database/schema'
-import type { CaseStudyOutcome, IssueType, LocationScale } from '../database/schema'
-import { CASE_STUDY_OUTCOMES, LOCATION_SCALES } from '../database/schema'
+import type { CaseStudyOutcome, HelpLabel, IssueType, LocationScale } from '../database/schema'
+import { CASE_STUDY_OUTCOMES, HELP_LABELS, LOCATION_SCALES } from '../database/schema'
import { findSimilar, generateEmbedding } from './embeddings'
import { issueWithRelations, transformIssue } from './transform-issue'
import { transformCaseStudy } from './case-study-write'
@@ -96,13 +96,16 @@ function caseStudyWhereSql(f: CaseStudyFilters): SQL {
export async function discoverCaseStudies(q: QueryRecord) {
const filters = parseCaseStudyFilters(q)
const query = ((q.query as string) || '').trim()
- const limit = clampInt(q.limit, 10, 25)
+ const limit = clampInt(q.limit, 10, 50)
// Semantic path: rank approved case studies by similarity to `query`.
+ let degraded = false
if (query.length >= 3) {
const ranked = await semanticCaseStudies(query, filters, limit)
- if (ranked) return ranked
- // ranked === null → embeddings unavailable; fall through to recency.
+ if (ranked) return { items: ranked, degraded: false }
+ // ranked === null → embeddings unavailable; fall through to recency, but
+ // flag it so the client knows the semantic query was ignored.
+ degraded = true
}
// Non-semantic path: filtered list, verified-first then most recent.
@@ -112,7 +115,7 @@ export async function discoverCaseStudies(q: QueryRecord) {
orderBy: [desc(caseStudies.verified), desc(caseStudies.createdAt)],
limit,
})
- return rows.map(transformCaseStudy)
+ return { items: rows.map(transformCaseStudy), degraded }
}
// Returns ranked results, or null when embeddings can't be computed (so the
@@ -372,3 +375,79 @@ export async function listNodesByTaxonomy(q: QueryRecord) {
})
return results.map((i) => transformIssue(i))
}
+
+// ── /api/contribute: nodes carrying help-wanted labels ──────────────
+// Backs the contribute view. Help labels live only here (never on a node card),
+// so users come to this surface specifically to find and close evidence gaps.
+
+type HelpRow = { label: string; count: number }
+
+/** Per-label counts across issues/solutions and case studies, for the facets. */
+export async function getHelpLabelCounts(): Promise> {
+ const db = useDB()
+ const counts = Object.fromEntries(HELP_LABELS.map((l) => [l, 0])) as Record
+ const [issueRows, csRows] = await Promise.all([
+ db.execute(sql`
+ SELECT unnest(help_labels) AS label, COUNT(*)::int AS count
+ FROM issues WHERE status = 'approved' AND cardinality(help_labels) > 0
+ GROUP BY 1`),
+ db.execute(sql`
+ SELECT unnest(help_labels) AS label, COUNT(*)::int AS count
+ FROM case_studies WHERE status = 'approved' AND cardinality(help_labels) > 0
+ GROUP BY 1`),
+ ])
+ for (const r of [...issueRows, ...csRows]) {
+ if (r.label in counts) counts[r.label as HelpLabel] += Number(r.count)
+ }
+ return counts
+}
+
+/**
+ * List nodes that need contribution. `label` narrows to one help label (else any
+ * node carrying at least one). `kind` selects issue/solution/case_study/any.
+ */
+export async function listNodesNeedingHelp(q: QueryRecord) {
+ const db = useDB()
+ const label = oneOf(q.label, HELP_LABELS)
+ const kind = oneOf(q.kind, ['issue', 'solution', 'case_study', 'any'] as const) ?? 'any'
+ const limit = clampInt(q.limit, 50, 100)
+
+ const wantIssues = kind === 'issue' || kind === 'solution' || kind === 'any'
+ const wantCaseStudies = kind === 'case_study' || kind === 'any'
+
+ const issueLabelFilter = label
+ ? sql`${issues.helpLabels} @> ARRAY[${label}]::text[]`
+ : sql`cardinality(${issues.helpLabels}) > 0`
+ const csLabelFilter = label
+ ? sql`${caseStudies.helpLabels} @> ARRAY[${label}]::text[]`
+ : sql`cardinality(${caseStudies.helpLabels}) > 0`
+
+ const [issueRows, csRows] = await Promise.all([
+ wantIssues
+ ? db.query.issues.findMany({
+ where: and(
+ eq(issues.status, 'approved'),
+ kind === 'issue' || kind === 'solution' ? eq(issues.type, kind) : undefined,
+ issueLabelFilter,
+ ),
+ with: issueWithRelations,
+ orderBy: nodeOrder('most_voted'),
+ limit,
+ })
+ : Promise.resolve([]),
+ wantCaseStudies
+ ? db.query.caseStudies.findMany({
+ where: and(eq(caseStudies.status, 'approved'), csLabelFilter),
+ with: caseStudyWith,
+ orderBy: desc(caseStudies.createdAt),
+ limit,
+ })
+ : Promise.resolve([]),
+ ])
+
+ return {
+ counts: await getHelpLabelCounts(),
+ issues: issueRows.map((i) => transformIssue(i)),
+ caseStudies: csRows.map((c) => transformCaseStudy(c)),
+ }
+}
diff --git a/server/utils/email-binding.ts b/server/utils/email-binding.ts
new file mode 100644
index 0000000..a7800d6
--- /dev/null
+++ b/server/utils/email-binding.ts
@@ -0,0 +1,36 @@
+import type { H3Event } from 'h3'
+
+// Cloudflare Email Sending binding shape — generated by `wrangler types`
+// includes the real SendEmail types, but Nitro doesn't pull that into our
+// server-side TS context, so we keep a minimal local type.
+export interface SendEmailBinding {
+ send(input: {
+ to: string | string[]
+ from: { email: string; name?: string }
+ subject: string
+ html: string
+ text: string
+ replyTo?: string
+ }): Promise<{ messageId: string }>
+}
+
+// Scheduled tasks run without an h3 event, so they can't reach the binding
+// via `event.context.cloudflare.env`. The `cloudflare:scheduled` hook (see
+// server/plugins/hyperdrive.ts) stashes it here for the duration of the cron
+// invocation instead. Bindings must not be reused across invocations on
+// Workers, but each scheduled event overwrites the slot before any task runs,
+// so the holder always points at the current invocation's binding.
+let scheduledEmailBinding: SendEmailBinding | undefined
+
+export function setScheduledEmailBinding(binding: SendEmailBinding | undefined): void {
+ scheduledEmailBinding = binding
+}
+
+/**
+ * Resolve the EMAIL binding for the current execution context: the request's
+ * env when called with an event, the scheduled-invocation stash otherwise.
+ */
+export function resolveEmailBinding(event?: H3Event | null): SendEmailBinding | undefined {
+ const ctx = event?.context as { cloudflare?: { env?: { EMAIL?: SendEmailBinding } } } | undefined
+ return ctx?.cloudflare?.env?.EMAIL ?? scheduledEmailBinding
+}
diff --git a/server/utils/email-revisions.ts b/server/utils/email-revisions.ts
index dfef09e..93c09dd 100644
--- a/server/utils/email-revisions.ts
+++ b/server/utils/email-revisions.ts
@@ -5,6 +5,7 @@
// instead (see server/utils/email.ts).
import type { H3Event } from 'h3'
import { sendEmail } from './email'
+import { escapeHtml } from './escape-html'
// Proposers below this trust score don't generate an owner notification email —
// the AI pre-screen + in-app inbox still surface their proposal, but we don't
@@ -48,15 +49,18 @@ export async function sendProposalNotification(
if (opts.proposerTrustScore < PROPOSAL_NOTIFY_MIN_TRUST) return
const url = nodeUrl(event, opts.targetKind, opts.issueId, opts.caseStudyId)
- const who = opts.proposerName?.trim() || 'A community member'
+ // Names, node titles, and the note are user-controlled — escape them so a
+ // proposer can't inject markup/links into the owner's inbox.
+ const who = escapeHtml(opts.proposerName?.trim() || 'A community member')
+ const label = escapeHtml(opts.nodeLabel)
try {
await sendEmail(event, {
to: opts.ownerEmail,
subject: `New suggested edit on "${opts.nodeLabel}"`,
html: `
- Hi${opts.ownerName ? ` ${opts.ownerName}` : ''},
- ${who} suggested an edit to ${opts.nodeLabel} .
- ${opts.note ? `"${opts.note}"
` : ''}
+ Hi${opts.ownerName ? ` ${escapeHtml(opts.ownerName)}` : ''},
+ ${who} suggested an edit to ${label} .
+ ${opts.note ? `"${escapeHtml(opts.note)}"
` : ''}
Review the suggestion to approve or reject it.
`,
})
@@ -102,9 +106,9 @@ export async function sendDecisionNotification(
to: opts.proposerEmail,
subject,
html: `
- Hi${opts.proposerName ? ` ${opts.proposerName}` : ''},
- Your suggested edit to ${opts.nodeLabel} was ${verb} .
- ${opts.reason ? `"${opts.reason}"
` : ''}
+ Hi${opts.proposerName ? ` ${escapeHtml(opts.proposerName)}` : ''},
+ Your suggested edit to ${escapeHtml(opts.nodeLabel)} was ${verb} .
+ ${opts.reason ? `"${escapeHtml(opts.reason)}"
` : ''}
View the node .
`,
})
diff --git a/server/utils/email.ts b/server/utils/email.ts
index 04d4a94..7168682 100644
--- a/server/utils/email.ts
+++ b/server/utils/email.ts
@@ -1,18 +1,5 @@
import type { H3Event } from 'h3'
-
-// Cloudflare Email Sending binding shape — generated by `wrangler types`
-// includes the real SendEmail types, but Nitro doesn't pull that into our
-// server-side TS context, so we keep a minimal local type.
-interface SendEmailBinding {
- send(input: {
- to: string | string[]
- from: { email: string; name?: string }
- subject: string
- html: string
- text: string
- replyTo?: string
- }): Promise<{ messageId: string }>
-}
+import { resolveEmailBinding } from './email-binding'
// Accepts "user@example.com" or "Display Name ".
function parseFromAddress(input: string): { email: string; name?: string } {
@@ -24,8 +11,11 @@ function parseFromAddress(input: string): { email: string; name?: string } {
return { email: input.trim() }
}
+// `event` is null when sending from a scheduled task (no request in flight);
+// the EMAIL binding then comes from the `cloudflare:scheduled` stash instead
+// of the request context — see server/utils/email-binding.ts.
export async function sendEmail(
- event: H3Event,
+ event: H3Event | null,
opts: {
to: string
subject: string
@@ -42,7 +32,7 @@ export async function sendEmail(
}
const text = opts.text ?? opts.html.replace(/<[^>]+>/g, '')
- const binding = (event.context as any).cloudflare?.env?.EMAIL as SendEmailBinding | undefined
+ const binding = resolveEmailBinding(event)
if (!binding) {
// In `bun run dev` there's no Cloudflare runtime, so the binding is
diff --git a/server/utils/escape-html.ts b/server/utils/escape-html.ts
new file mode 100644
index 0000000..3a18075
--- /dev/null
+++ b/server/utils/escape-html.ts
@@ -0,0 +1,11 @@
+/**
+ * Escape a user-supplied string for interpolation into an HTML document or
+ * email body. Anything that originates from a request body or a user-editable
+ * field (names, titles, notes) must pass through this before landing in HTML.
+ */
+export function escapeHtml(s: string): string {
+ return s.replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
+ )
+}
diff --git a/server/utils/issue-counters.ts b/server/utils/issue-counters.ts
new file mode 100644
index 0000000..c9adb8d
--- /dev/null
+++ b/server/utils/issue-counters.ts
@@ -0,0 +1,38 @@
+import { eq, sql, type SQL } from 'drizzle-orm'
+import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
+import type * as schema from '../database/schema'
+import { issues, type IssueType } from '../database/schema'
+
+// Accepts the plain db as well as a transaction handle, in the Nuxt server and
+// the moderation worker alike (both are postgres-js drizzle over this schema).
+type CounterExecutor = Pick, 'update'>
+
+function parentCounterAdjustment(
+ type: IssueType,
+ delta: 1 | -1,
+): { solutionCount: SQL } | { subIssueCount: SQL } {
+ const column = type === 'solution' ? issues.solutionCount : issues.subIssueCount
+ const value = delta === 1 ? sql`${column} + 1` : sql`GREATEST(${column} - 1, 0)`
+ return type === 'solution' ? { solutionCount: value } : { subIssueCount: value }
+}
+
+// The denormalized child tallies on a parent node (`solutionCount` /
+// `subIssueCount`) move whenever a child is created, re-parented, or flips
+// between counted (pending/approved) and not counted (rejected). This is the
+// single write path for those moves; `node` is the child (or, for re-parents,
+// an explicit `{ parentId, type }` naming the parent gaining/losing it).
+//
+// No-ops on a null parentId. Decrements clamp at 0: a double-decrement (e.g.
+// rejecting an already uncounted node after a race) must not drive a tally
+// negative.
+export async function adjustParentCounter(
+ db: CounterExecutor,
+ node: { parentId?: number | null; type: IssueType },
+ delta: 1 | -1,
+): Promise {
+ if (!node.parentId) return
+ await db
+ .update(issues)
+ .set(parentCounterAdjustment(node.type, delta))
+ .where(eq(issues.id, node.parentId))
+}
diff --git a/server/utils/issue-list.ts b/server/utils/issue-list.ts
new file mode 100644
index 0000000..9af3470
--- /dev/null
+++ b/server/utils/issue-list.ts
@@ -0,0 +1,37 @@
+import { and, asc, desc, sql, type SQL } from 'drizzle-orm'
+import { issues } from '../database/schema'
+import { issueWithRelations, transformIssue } from './transform-issue'
+import { withMembers } from './node-members'
+
+type IssueListSort = 'newest' | 'most_voted' | 'trending'
+
+// HN-style ranking: engagement / (age_hours + 2) ^ gravity. Solutions (3x) and
+// sub-issues (2x) weigh more than raw votes. See docs/ranking-and-trust.md.
+const trendingOrder = sql`(
+ ${issues.voteScore} + ${issues.solutionCount} * 3 + ${issues.subIssueCount} * 2
+)::float / POWER(EXTRACT(EPOCH FROM (NOW() - ${issues.createdAt})) / 3600 + 2, 1.5) DESC`
+
+const sortOrders: Record = {
+ oldest: asc(issues.createdAt),
+ newest: desc(issues.createdAt),
+ most_voted: desc(issues.voteScore),
+ trending: trendingOrder,
+}
+
+function issueListOrderBy(sortBy: string, fallback: IssueListSort): SQL {
+ return sortOrders[sortBy] ?? sortOrders[fallback]!
+}
+
+// Shared tail of every issue/solution listing endpoint: filter, sort, load
+// relations, transform for the card UI, attach the member avatar stacks.
+export async function listIssueNodes(conditions: SQL[], sortBy: string, fallback: IssueListSort) {
+ const results = await useDB().query.issues.findMany({
+ where: and(...conditions),
+ with: issueWithRelations,
+ orderBy: issueListOrderBy(sortBy, fallback),
+ })
+ return withMembers(
+ 'issue',
+ results.map((i) => transformIssue(i)),
+ )
+}
diff --git a/server/utils/issue-write.ts b/server/utils/issue-write.ts
index c2554a4..4bbd93a 100644
--- a/server/utils/issue-write.ts
+++ b/server/utils/issue-write.ts
@@ -1,7 +1,7 @@
// Shared write-side logic for issues. Both /api/issue/index.post.ts (REST)
// and the MCP create_issue tool call into here so input sanitization,
// counter bumps, and the moderation trigger stay in one place.
-import { eq, sql } from 'drizzle-orm'
+import { eq } from 'drizzle-orm'
import { issues, users } from '../database/schema'
import type { IssueType, LocationScale, SolutionStatus } from '../database/schema'
import { assertNotBanned } from './check-ban'
@@ -138,13 +138,7 @@ export async function createIssue(authorId: string, input: CreateIssueInput) {
.returning()
const created = rows[0]!
- if (input.parentId) {
- const counter =
- type === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} + 1` }
- : { subIssueCount: sql`${issues.subIssueCount} + 1` }
- await db.update(issues).set(counter).where(eq(issues.id, input.parentId))
- }
+ await adjustParentCounter(db, { parentId: input.parentId, type }, 1)
// Bootstrap version history: a born-approved "Created" revision whose
// before-snapshot is the origin ({}) and after-snapshot is the new node.
@@ -342,25 +336,6 @@ export async function updateIssue(
const rows = await tx.update(issues).set(patch).where(eq(issues.id, input.id)).returning()
const updated = rows[0]!
- const inc = (parentId: number) =>
- tx
- .update(issues)
- .set(
- existing.type === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} + 1` }
- : { subIssueCount: sql`${issues.subIssueCount} + 1` },
- )
- .where(eq(issues.id, parentId))
- const dec = (parentId: number) =>
- tx
- .update(issues)
- .set(
- existing.type === 'solution'
- ? { solutionCount: sql`${issues.solutionCount} - 1` }
- : { subIssueCount: sql`${issues.subIssueCount} - 1` },
- )
- .where(eq(issues.id, parentId))
-
if (parentChanged) {
// Move the counter off the old parent and onto the new one, mirroring
// createIssue's bookkeeping. A node counts toward its parent unless it's
@@ -370,12 +345,14 @@ export async function updateIssue(
// edit elsewhere never decrements the parent.
const wasCounted = existing.status !== 'rejected'
const isCounted = existing.status !== 'rejected' || contentChanged
- if (wasCounted && existing.parentId) await dec(existing.parentId)
- if (isCounted && newParentId) await inc(newParentId)
- } else if (rejectedToPending && existing.parentId) {
+ if (wasCounted) await adjustParentCounter(tx, existing, -1)
+ if (isCounted) {
+ await adjustParentCounter(tx, { parentId: newParentId, type: existing.type }, 1)
+ }
+ } else if (rejectedToPending) {
// Same-parent rejection reversal: re-bump the parent the node still lives
// under (rejection had decremented it).
- await inc(existing.parentId)
+ await adjustParentCounter(tx, existing, 1)
}
return updated
diff --git a/server/utils/mcp-schemas.ts b/server/utils/mcp-schemas.ts
index 5facb83..a231ab9 100644
--- a/server/utils/mcp-schemas.ts
+++ b/server/utils/mcp-schemas.ts
@@ -10,6 +10,7 @@ const proposeKind = z.enum(['issue', 'solution', 'case_study'])
const latitude = z.number().min(-90).max(90)
const longitude = z.number().min(-180).max(180)
const id = z.number().int().positive()
+const model = z.string().min(1).max(200).optional()
const link = z.object({ url: z.string().min(1), title: z.string().optional() })
const metric = z.object({
label: z.string().min(1),
@@ -34,6 +35,7 @@ export const mcpToolInputSchemas = {
get_issue: z.object({ id }),
get_tree: z.object({ id }),
create_issue: z.object({
+ model,
title: z.string().min(1),
summary: z.string().min(1),
description: z.string().optional(),
@@ -41,6 +43,7 @@ export const mcpToolInputSchemas = {
...locationFields,
}),
create_solution: z.object({
+ model,
title: z.string().min(1),
summary: z.string().min(1),
description: z.string().optional(),
@@ -49,6 +52,7 @@ export const mcpToolInputSchemas = {
...locationFields,
}),
update_issue: z.object({
+ model,
id,
title: z.string().min(1).optional(),
summary: z.string().min(1).optional(),
@@ -56,6 +60,7 @@ export const mcpToolInputSchemas = {
...locationFields,
}),
update_solution: z.object({
+ model,
id,
title: z.string().min(1).optional(),
summary: z.string().min(1).optional(),
@@ -74,6 +79,7 @@ export const mcpToolInputSchemas = {
get_case_study: z.object({ id }),
list_case_studies: z.object({ id }),
create_case_study: z.object({
+ model,
solutionId: id,
outcome,
locationName: z.string().min(1),
@@ -93,6 +99,7 @@ export const mcpToolInputSchemas = {
links: z.array(link).optional(),
}),
update_case_study: z.object({
+ model,
id,
outcome: outcome.optional(),
locationName: z.string().min(1).optional(),
@@ -116,6 +123,7 @@ export const mcpToolInputSchemas = {
// fields are the union of the per-kind edit fields and are validated when
// present. The handler routes to the right update_* path by `kind`.
propose_edit: z.object({
+ model,
kind: proposeKind,
id,
note: z.string().nullish(),
diff --git a/server/utils/newsletter-digest.ts b/server/utils/newsletter-digest.ts
new file mode 100644
index 0000000..4eb0efa
--- /dev/null
+++ b/server/utils/newsletter-digest.ts
@@ -0,0 +1,279 @@
+// Digest assembly for the newsletter. Splits into two layers so the send
+// loop stays cheap: `buildRunContext` computes everything that is identical
+// for every recipient once per cron run (good news, help wanted, product
+// updates, the pool of recent wanted skills), `buildUserDigest` then adds the
+// per-user sections (interest matches via pgvector, skill matches via text
+// match against the shared pool) and filters down to the blocks the user
+// opted into. Templates live in newsletter-email.ts.
+import { and, desc, eq, gte, sql } from 'drizzle-orm'
+import {
+ caseStudies,
+ issues,
+ qualifications,
+ userInterests,
+ wantedSkills,
+ type NewsletterContent,
+} from '../database/schema'
+import { findSimilar } from './embeddings'
+import { PRODUCT_UPDATES } from './newsletter-updates'
+
+export const NEWSLETTER_BASE_URL = 'https://communityfix.org'
+
+const SECTION_LIMIT = 5
+// Interest → issue similarity floor. Above the case-study search floor (0.2)
+// because an email match should be clearly on-topic, not merely adjacent.
+const INTEREST_SIMILARITY_THRESHOLD = 0.3
+// Skill/qualification strings shorter than this never text-match — two-letter
+// areas like "IT" would substring-match half the catalog.
+const MIN_SKILL_MATCH_LENGTH = 4
+
+export interface DigestItem {
+ title: string
+ url: string
+ summary?: string
+ /** Small muted line under the summary, e.g. `Rotterdam, NL · success`. */
+ meta?: string
+}
+
+export interface DigestSection {
+ key: keyof NewsletterContent
+ title: string
+ items: DigestItem[]
+ footerLink?: { label: string; url: string }
+}
+
+/** Everything shared across recipients of one send run. */
+export interface DigestRunContext {
+ since: Date
+ goodNews: DigestItem[]
+ helpWanted: DigestItem[]
+ productUpdates: DigestItem[]
+ recentWantedSkills: Array<{
+ skill: string
+ issueId: number
+ title: string
+ summary: string
+ }>
+}
+
+function truncate(s: string, max = 180): string {
+ if (s.length <= max) return s
+ const cut = s.slice(0, max)
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), max - 20))}…`
+}
+
+function issueUrl(id: number): string {
+ return `${NEWSLETTER_BASE_URL}/issue/${id}`
+}
+
+async function loadGoodNews(since: Date): Promise {
+ const rows = await useDB().query.caseStudies.findMany({
+ where: and(
+ eq(caseStudies.status, 'approved'),
+ eq(caseStudies.outcome, 'success'),
+ gte(caseStudies.createdAt, since),
+ ),
+ with: { solution: { columns: { title: true } } },
+ orderBy: [desc(caseStudies.verified), desc(caseStudies.createdAt)],
+ limit: SECTION_LIMIT,
+ })
+ return rows.map((cs) => ({
+ title: cs.solution.title,
+ url: `${NEWSLETTER_BASE_URL}/case-study/${cs.id}`,
+ summary: cs.description ? truncate(cs.description) : undefined,
+ meta: [cs.locationName, cs.implementer, cs.verified ? 'verified' : undefined]
+ .filter(Boolean)
+ .join(' · '),
+ }))
+}
+
+async function loadHelpWanted(since: Date): Promise {
+ const rows = await useDB().query.issues.findMany({
+ where: and(
+ eq(issues.status, 'approved'),
+ gte(issues.updatedAt, since),
+ sql`cardinality(${issues.helpLabels}) > 0`,
+ ),
+ columns: { id: true, title: true, summary: true, helpLabels: true, type: true },
+ orderBy: desc(issues.voteScore),
+ limit: SECTION_LIMIT,
+ })
+ return rows.map((i) => ({
+ title: i.title,
+ url: issueUrl(i.id),
+ summary: truncate(i.summary),
+ meta: [i.type, ...i.helpLabels.map((l) => l.replace(/-/g, ' '))].join(' · '),
+ }))
+}
+
+function loadProductUpdates(since: Date): DigestItem[] {
+ return PRODUCT_UPDATES.filter((u) => new Date(`${u.date}T00:00:00Z`) >= since)
+ .sort((a, b) => b.date.localeCompare(a.date))
+ .slice(0, SECTION_LIMIT)
+ .map((u) => ({ title: u.title, url: u.url ?? NEWSLETTER_BASE_URL, summary: u.blurb }))
+}
+
+async function loadRecentWantedSkills(
+ since: Date,
+): Promise {
+ const rows = await useDB()
+ .select({
+ skill: wantedSkills.skill,
+ issueId: issues.id,
+ title: issues.title,
+ summary: issues.summary,
+ })
+ .from(wantedSkills)
+ .innerJoin(issues, eq(issues.id, wantedSkills.issueId))
+ .where(and(eq(issues.status, 'approved'), gte(wantedSkills.createdAt, since)))
+ return rows
+}
+
+export async function buildRunContext(windowDays: number): Promise {
+ const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000)
+ const [goodNews, helpWanted, recentWantedSkills] = await Promise.all([
+ loadGoodNews(since),
+ loadHelpWanted(since),
+ loadRecentWantedSkills(since),
+ ])
+ return {
+ since,
+ goodNews,
+ helpWanted,
+ productUpdates: loadProductUpdates(since),
+ recentWantedSkills,
+ }
+}
+
+// ── Per-user sections ────────────────────────────────────────────────
+
+type InterestHit = { id: number; title: string; summary: string; similarity: number }
+
+async function matchInterests(userId: string, since: Date): Promise {
+ const interests = await useDB().query.userInterests.findMany({
+ where: and(eq(userInterests.userId, userId), sql`${userInterests.embedding} IS NOT NULL`),
+ orderBy: desc(userInterests.createdAt),
+ limit: 10,
+ })
+
+ // Best similarity per issue across all interests, remembering which
+ // interest label produced the match for the meta line.
+ const best = new Map()
+ for (const interest of interests) {
+ const hits = await findSimilar({
+ table: 'issues',
+ columns: 'id, title, summary',
+ embedding: interest.embedding!,
+ where: sql`status = 'approved' AND created_at >= ${since}`,
+ limit: SECTION_LIMIT,
+ threshold: INTEREST_SIMILARITY_THRESHOLD,
+ })
+ for (const hit of hits) {
+ const prev = best.get(hit.id)
+ if (!prev || hit.similarity > prev.similarity) {
+ best.set(hit.id, { ...hit, label: interest.label })
+ }
+ }
+ }
+
+ return [...best.values()]
+ .sort((a, b) => b.similarity - a.similarity)
+ .slice(0, SECTION_LIMIT)
+ .map((hit) => ({
+ title: hit.title,
+ url: issueUrl(hit.id),
+ summary: truncate(hit.summary),
+ meta: `matches “${hit.label}”`,
+ }))
+}
+
+// Case-insensitive containment in either direction between what the user can
+// do and what the node asks for. Crude next to the embedding path above, but
+// wanted skills and qualification areas are short noun phrases where
+// containment ("civil engineering" ⊂ "senior civil engineering") does most of
+// the work an embedding would.
+function skillsOverlap(wanted: string, offered: string): boolean {
+ const w = wanted.trim().toLowerCase()
+ const o = offered.trim().toLowerCase()
+ if (w.length < MIN_SKILL_MATCH_LENGTH || o.length < MIN_SKILL_MATCH_LENGTH) return false
+ return w.includes(o) || o.includes(w)
+}
+
+async function matchSkills(
+ userId: string,
+ pool: DigestRunContext['recentWantedSkills'],
+): Promise {
+ if (pool.length === 0) return []
+ const quals = await useDB().query.qualifications.findMany({
+ where: eq(qualifications.userId, userId),
+ columns: { title: true, area: true },
+ })
+ if (quals.length === 0) return []
+
+ const offered = quals.flatMap((q) => [q.area, q.title])
+ const byIssue = new Map()
+ for (const ws of pool) {
+ if (!offered.some((o) => skillsOverlap(ws.skill, o))) continue
+ const entry = byIssue.get(ws.issueId)
+ if (entry) {
+ entry.skills.push(ws.skill)
+ } else {
+ byIssue.set(ws.issueId, {
+ item: { title: ws.title, url: issueUrl(ws.issueId), summary: truncate(ws.summary) },
+ skills: [ws.skill],
+ })
+ }
+ }
+
+ return [...byIssue.values()].slice(0, SECTION_LIMIT).map(({ item, skills }) => ({
+ ...item,
+ meta: `looking for ${skills.join(', ')}`,
+ }))
+}
+
+/**
+ * Sections for one recipient, personal blocks first, already filtered to the
+ * blocks they opted into and to sections that actually have items. An empty
+ * return means "send nothing".
+ */
+export async function buildUserDigest(
+ ctx: DigestRunContext,
+ userId: string,
+ content: NewsletterContent,
+): Promise {
+ const sections: DigestSection[] = []
+
+ if (content.topicMatches) {
+ sections.push({
+ key: 'topicMatches',
+ title: 'New in your interests',
+ items: await matchInterests(userId, ctx.since),
+ })
+ }
+ if (content.skillMatches) {
+ sections.push({
+ key: 'skillMatches',
+ title: 'Your skills are wanted',
+ items: await matchSkills(userId, ctx.recentWantedSkills),
+ })
+ }
+ if (content.goodNews) {
+ sections.push({ key: 'goodNews', title: 'Good news', items: ctx.goodNews })
+ }
+ if (content.helpWanted) {
+ sections.push({
+ key: 'helpWanted',
+ title: 'Help wanted',
+ items: ctx.helpWanted,
+ footerLink: {
+ label: 'See everything that needs a hand',
+ url: `${NEWSLETTER_BASE_URL}/contribute`,
+ },
+ })
+ }
+ if (content.productUpdates) {
+ sections.push({ key: 'productUpdates', title: 'Product updates', items: ctx.productUpdates })
+ }
+
+ return sections.filter((s) => s.items.length > 0)
+}
diff --git a/server/utils/newsletter-email.ts b/server/utils/newsletter-email.ts
new file mode 100644
index 0000000..27442b6
--- /dev/null
+++ b/server/utils/newsletter-email.ts
@@ -0,0 +1,186 @@
+// Newsletter email templates, styled after the dashboard bento: rounded white
+// cards on a soft gray page, light-gray corner index numbers ("01"), a blue
+// letterspaced kicker over a big condensed greeting, pill chips, hairline row
+// dividers, and a blue pill button — the brand's rounded-cards surface
+// language (decorative *lines* stay sharp; cards stay rounded).
+// Email constraints shape the build: tables, inline styles, no images, no
+// motion. border-radius degrades to square corners in old Outlook, which is
+// acceptable. Oswald is requested via Google Fonts for clients that honor it
+// (Apple Mail); everywhere else the condensed fallback stack keeps the shape.
+// Every dynamic string is escaped: titles, summaries, and meta lines carry
+// user-authored content.
+import type { NewsletterFrequency } from '../database/schema'
+import type { DigestSection } from './newsletter-digest'
+import { NEWSLETTER_BASE_URL } from './newsletter-digest'
+import { escapeHtml } from './escape-html'
+
+const BLUE = '#155dfc' // Tailwind v4 blue-600 — the site's primary accent
+// The site's prose links underline with `decoration-primary/40`; email has no
+// alpha-safe decoration color, so this is blue-600 at 40% flattened onto white.
+const BLUE_SOFT = '#a1befe'
+const INK = '#111113'
+const BODY = '#52525b' // zinc-600 — summaries + intro
+const MUTED = '#a1a1aa' // zinc-400 — meta lines, footer
+const INDEX = '#b9b9c0' // card corner numbers, like the dashboard stat cards
+const PAGE_BG = '#ededee' // soft gray page behind the cards
+const CARD_BG = '#ffffff'
+const DIVIDER = '#efeff1' // hairline between rows inside a card
+const CHIP_BORDER = '#e4e4e7'
+const RADIUS = '24px'
+
+const FONT_BODY = "'Helvetica Neue', Helvetica, Arial, sans-serif"
+const FONT_DISPLAY = "'Oswald', 'Arial Narrow', 'Helvetica Neue', Arial, sans-serif"
+
+const MONTHS = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+] as const
+
+function folioDate(d: Date): string {
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`
+}
+
+function chip(label: string, href?: string): string {
+ const style = `display:inline-block;border:1px solid ${CHIP_BORDER};border-radius:999px;padding:7px 14px;font-family:${FONT_DISPLAY};font-size:10px;font-weight:600;letter-spacing:0.14em;text-transform:uppercase;color:${BODY};text-decoration:none;`
+ return href
+ ? `${escapeHtml(label)} `
+ : `${escapeHtml(label)} `
+}
+
+/** 16px gap between cards. */
+function gap(): string {
+ return ` `
+}
+
+function itemHtml(
+ item: { title: string; url: string; summary?: string; meta?: string },
+ first: boolean,
+): string {
+ return `
+
+ ${escapeHtml(item.title)}
+ ${item.summary ? `${escapeHtml(item.summary)}
` : ''}
+ ${item.meta ? `${escapeHtml(item.meta)}
` : ''}
+ `
+}
+
+function sectionCardHtml(section: DigestSection, index: number): string {
+ const number = String(index + 1).padStart(2, '0')
+ return `${index > 0 ? gap() : ''}
+
+
+ `
+}
+
+function sectionText(section: DigestSection, index: number): string {
+ const number = String(index + 1).padStart(2, '0')
+ const items = section.items
+ .map((i) =>
+ [`- ${i.title}`, i.summary ? ` ${i.summary}` : null, ` ${i.url}`]
+ .filter(Boolean)
+ .join('\n'),
+ )
+ .join('\n\n')
+ const footer = section.footerLink
+ ? `\n\n${section.footerLink.label}: ${section.footerLink.url}`
+ : ''
+ return `${number} / ${section.title.toUpperCase()}\n\n${items}${footer}`
+}
+
+export function renderNewsletterEmail(opts: {
+ name: string | null
+ frequency: NewsletterFrequency
+ sections: DigestSection[]
+ unsubscribeUrl: string
+ /** Send date shown in the header chip. Defaults to now. */
+ date?: Date
+}): { subject: string; html: string; text: string } {
+ const { name, frequency, sections, unsubscribeUrl } = opts
+ const subject = `Your ${frequency} digest — CommunityFix`
+ const settingsUrl = `${NEWSLETTER_BASE_URL}/settings`
+ const heading = name ? `Hi ${name}` : 'Hello'
+ const period = frequency === 'weekly' ? 'week' : 'month'
+ const intro = `Here is what happened on the commons this ${period}.`
+ const dateLabel = folioDate(opts.date ?? new Date())
+
+ const html = `
+
+
+
+
+
+
+ ${escapeHtml(subject)}
+
+
+ ${escapeHtml(sections.map((s) => s.title).join(' · '))}
+
+
+
+
+
+ CommunityFix
+
+
+
+
+ ${frequency} digest
+ ${escapeHtml(heading)}
+ ${escapeHtml(intro)}
+ ${chip(dateLabel)} ${chip('communityfix.org', NEWSLETTER_BASE_URL)}
+
+
+ ${gap()}
+
+ ${sections.map(sectionCardHtml).join('')}
+
+
+ You are receiving this because you subscribed to the ${frequency} CommunityFix newsletter.
+
+
+ Manage preferences
+ ·
+ Unsubscribe
+
+
+
+
+
+
+`
+
+ const text = [
+ 'COMMUNITYFIX',
+ `${frequency} digest · ${dateLabel} · communityfix.org`,
+ '',
+ `${heading}. ${intro}`,
+ '',
+ sections.map(sectionText).join('\n\n'),
+ '',
+ `You are receiving this because you subscribed to the ${frequency} CommunityFix newsletter.`,
+ `Manage preferences: ${settingsUrl}`,
+ `Unsubscribe: ${unsubscribeUrl}`,
+ ].join('\n')
+
+ return { subject, html, text }
+}
diff --git a/server/utils/newsletter-send.ts b/server/utils/newsletter-send.ts
new file mode 100644
index 0000000..780814a
--- /dev/null
+++ b/server/utils/newsletter-send.ts
@@ -0,0 +1,107 @@
+// The newsletter send loop, called by the newsletter:weekly / :monthly cron
+// tasks. Mirrors compute:trust-scores' shape: recipients are processed in
+// small chunks so the run fits the Workers cron CPU budget, and one failing
+// recipient never aborts the batch. Digest content comes from
+// newsletter-digest.ts, templates from newsletter-email.ts.
+import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'
+import { newsletterPrefs, users, type NewsletterFrequency } from '../database/schema'
+import { buildRunContext, buildUserDigest, NEWSLETTER_BASE_URL } from './newsletter-digest'
+import { renderNewsletterEmail } from './newsletter-email'
+import { issueUnsubscribeToken } from './newsletter-token'
+import { sendEmail } from './email'
+
+const CHUNK_SIZE = 10
+
+// How far back each digest looks.
+const WINDOW_DAYS: Record = { weekly: 7, monthly: 31 }
+
+// Double-send guard: skip anyone who already got a digest this close to now.
+// Below the nominal period so a cron that fires slightly early (or a retried
+// invocation) doesn't drop a legitimate send, but a same-day re-run is a no-op.
+const MIN_RESEND_DAYS: Record = { weekly: 5, monthly: 25 }
+
+export interface NewsletterRunResult {
+ frequency: NewsletterFrequency
+ recipients: number
+ sent: number
+ /** Recipients whose digest came back empty — nothing was sent to them. */
+ empty: number
+ failures: Array<{ userId: string; error: string }>
+}
+
+export async function runNewsletterSend(
+ frequency: NewsletterFrequency,
+): Promise {
+ const db = useDB()
+ const resendCutoff = new Date(Date.now() - MIN_RESEND_DAYS[frequency] * 24 * 60 * 60 * 1000)
+
+ const recipients = await db
+ .select({
+ userId: newsletterPrefs.userId,
+ content: newsletterPrefs.content,
+ email: users.email,
+ name: users.name,
+ })
+ .from(newsletterPrefs)
+ .innerJoin(users, eq(users.id, newsletterPrefs.userId))
+ .where(
+ and(
+ eq(newsletterPrefs.enabled, true),
+ eq(newsletterPrefs.frequency, frequency),
+ or(isNull(newsletterPrefs.lastSentAt), lt(newsletterPrefs.lastSentAt, resendCutoff)),
+ or(isNull(users.bannedUntil), lt(users.bannedUntil, sql`now()`)),
+ ),
+ )
+
+ const result: NewsletterRunResult = {
+ frequency,
+ recipients: recipients.length,
+ sent: 0,
+ empty: 0,
+ failures: [],
+ }
+ if (recipients.length === 0) return result
+
+ const ctx = await buildRunContext(WINDOW_DAYS[frequency])
+
+ for (let i = 0; i < recipients.length; i += CHUNK_SIZE) {
+ const chunk = recipients.slice(i, i + CHUNK_SIZE)
+ const outcomes = await Promise.allSettled(
+ chunk.map(async (r) => {
+ // A null content jsonb means nothing was ever picked — empty digest.
+ const sections = r.content ? await buildUserDigest(ctx, r.userId, r.content) : []
+ if (sections.length === 0) return 'empty' as const
+
+ const token = await issueUnsubscribeToken(r.userId)
+ const { subject, html, text } = renderNewsletterEmail({
+ name: r.name,
+ frequency,
+ sections,
+ unsubscribeUrl: `${NEWSLETTER_BASE_URL}/api/newsletter/unsubscribe?token=${token}`,
+ })
+ await sendEmail(null, { to: r.email, subject, html, text })
+
+ // Only after the send succeeded, so a failed recipient is retried by
+ // the next run instead of being silently marked as served.
+ await db
+ .update(newsletterPrefs)
+ .set({ lastSentAt: sql`now()` })
+ .where(eq(newsletterPrefs.userId, r.userId))
+ return 'sent' as const
+ }),
+ )
+ outcomes.forEach((outcome, idx) => {
+ const userId = chunk[idx]!.userId
+ if (outcome.status === 'fulfilled') {
+ result[outcome.value === 'sent' ? 'sent' : 'empty']++
+ } else {
+ const message =
+ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
+ console.error(`[newsletter:${frequency}] failed for user ${userId}:`, outcome.reason)
+ result.failures.push({ userId, error: message })
+ }
+ })
+ }
+
+ return result
+}
diff --git a/server/utils/newsletter-token.ts b/server/utils/newsletter-token.ts
new file mode 100644
index 0000000..734eee7
--- /dev/null
+++ b/server/utils/newsletter-token.ts
@@ -0,0 +1,45 @@
+// Stateless unsubscribe tokens for newsletter emails. The link must work for
+// a logged-out recipient, so the token itself proves the request is for the
+// right account: an HMAC over the user id, keyed on the session password
+// (same secret strategy as the OAuth consent CSRF token in oauth.ts).
+// Deliberately non-expiring — an unsubscribe link at the bottom of an old
+// email should keep working.
+
+function unsubscribeSecret(): string {
+ const secret =
+ (useRuntimeConfig() as { session?: { password?: string } }).session?.password ??
+ process.env.NUXT_SESSION_PASSWORD
+ if (!secret)
+ throw createError({ statusCode: 500, statusMessage: 'Session secret is not configured' })
+ return secret
+}
+
+async function hmacHex(secret: string, data: string): Promise {
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ )
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data))
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('')
+}
+
+/** Token format: `.` — the id is public, the sig binds it. */
+export async function issueUnsubscribeToken(userId: string): Promise {
+ return `${userId}.${await hmacHex(unsubscribeSecret(), `newsletter-unsub|${userId}`)}`
+}
+
+/** Returns the user id the token was issued for, or null when invalid. */
+export async function verifyUnsubscribeToken(token: string): Promise {
+ const dot = token.indexOf('.')
+ if (dot <= 0) return null
+ const userId = token.slice(0, dot)
+ const expected = await issueUnsubscribeToken(userId)
+ if (token.length !== expected.length) return null
+ // Constant-time compare to avoid leaking the signature byte-by-byte.
+ let diff = 0
+ for (let i = 0; i < token.length; i++) diff |= token.charCodeAt(i) ^ expected.charCodeAt(i)
+ return diff === 0 ? userId : null
+}
diff --git a/server/utils/newsletter-updates.ts b/server/utils/newsletter-updates.ts
new file mode 100644
index 0000000..dbc4b74
--- /dev/null
+++ b/server/utils/newsletter-updates.ts
@@ -0,0 +1,30 @@
+// Product updates surfaced in the newsletter's "Product updates" block.
+// Deliberately a code-side constant rather than a content collection:
+// scheduled tasks have no h3 event or D1 access for `queryCollection`, and
+// updates ship with deploys anyway. Add an entry here when a user-facing
+// feature lands; entries older than the digest window simply stop appearing.
+export interface ProductUpdate {
+ /** UTC day the update shipped, YYYY-MM-DD. */
+ date: string
+ title: string
+ blurb: string
+ /** Absolute URL; defaults to the site root in the email when omitted. */
+ url?: string
+}
+
+export const PRODUCT_UPDATES: ProductUpdate[] = [
+ {
+ date: '2026-07-13',
+ title: 'Wanted skills on issues',
+ blurb:
+ 'Issues can now list the skills they are missing. Add credentials to your profile and the newsletter will match you with issues that need them.',
+ url: 'https://communityfix.org/contribute',
+ },
+ {
+ date: '2026-07-13',
+ title: 'A newsletter you compose yourself',
+ blurb:
+ 'Pick exactly which blocks you want — success stories, skill matches, your interests, help wanted — and how often. This email is the result.',
+ url: 'https://communityfix.org/settings',
+ },
+]
diff --git a/server/utils/revision-write.ts b/server/utils/revision-write.ts
index e696d10..7dbf0fd 100644
--- a/server/utils/revision-write.ts
+++ b/server/utils/revision-write.ts
@@ -403,32 +403,54 @@ export async function decideRevision(
try {
appliedSnapshot = await applyRevision(userId, revision)
} catch (err) {
- // Apply failed — release the claim so the proposal stays decidable.
+ // Apply failed — release the claim so the proposal stays decidable. The
+ // compensation can fail too (e.g. the same DB blip that broke apply); in
+ // that case the row is stranded `approved` with the node unchanged, so
+ // log it loudly with the id for manual repair instead of masking the
+ // original apply error.
+ try {
+ await db
+ .update(revisions)
+ .set({
+ status: 'pending',
+ decidedById: null,
+ decidedByRole: null,
+ decisionReason: null,
+ decidedAt: null,
+ updatedAt: new Date(),
+ })
+ .where(eq(revisions.id, revision.id))
+ } catch (revertErr) {
+ console.error(
+ `[revision] CRITICAL: apply failed for revision ${revision.id} and the revert to pending also failed — row is approved but the node is unchanged, repair manually:`,
+ revertErr,
+ )
+ }
+ throw err
+ }
+ // Past this point the node is patched and the approval must stand — a
+ // revert to pending here would let the revision be applied twice. The
+ // ledger writes are therefore best-effort: log loudly on failure rather
+ // than fail an approval that already took effect.
+ try {
await db
.update(revisions)
- .set({
- status: 'pending',
- decidedById: null,
- decidedByRole: null,
- decisionReason: null,
- decidedAt: null,
- updatedAt: new Date(),
- })
+ .set({ appliedSnapshot, updatedAt: new Date() })
.where(eq(revisions.id, revision.id))
- throw err
+ await createAuditLog({
+ type: 'moderation',
+ action: 'revise',
+ issueId: revision.issueId ?? null,
+ userId,
+ reason: reason ?? null,
+ details: { revisionId: revision.id, role },
+ })
+ } catch (ledgerErr) {
+ console.error(
+ `[revision] revision ${revision.id} was applied but recording appliedSnapshot/audit failed:`,
+ ledgerErr,
+ )
}
- await db
- .update(revisions)
- .set({ appliedSnapshot, updatedAt: new Date() })
- .where(eq(revisions.id, revision.id))
- await createAuditLog({
- type: 'moderation',
- action: 'revise',
- issueId: revision.issueId ?? null,
- userId,
- reason: reason ?? null,
- details: { revisionId: revision.id, role },
- })
} else {
await db
.update(revisions)
diff --git a/server/utils/route-guards.ts b/server/utils/route-guards.ts
new file mode 100644
index 0000000..5f81c0e
--- /dev/null
+++ b/server/utils/route-guards.ts
@@ -0,0 +1,45 @@
+import type { H3Event } from 'h3'
+import { eq } from 'drizzle-orm'
+import { issues } from '../database/schema'
+
+// Parse a numeric route param, 400ing on anything non-numeric. `label` names
+// the resource in the error message: requireIdParam(event, { label: 'skill',
+// name: 'skillId' }) → "Invalid skill ID".
+export function requireIdParam(
+ event: H3Event,
+ { name = 'id', label = 'issue' }: { name?: string; label?: string } = {},
+): number {
+ const raw = getRouterParam(event, name)
+ if (!raw || isNaN(parseInt(raw, 10))) {
+ throw createError({ statusCode: 400, statusMessage: `Invalid ${label} ID` })
+ }
+ return parseInt(raw, 10)
+}
+
+// 404 unless an issue/solution row with this id exists. For handlers that only
+// need existence — ones that read further columns do their own findFirst.
+export async function assertIssueExists(issueId: number): Promise {
+ const node = await useDB().query.issues.findFirst({
+ where: eq(issues.id, issueId),
+ columns: { id: true },
+ })
+ if (!node) throw createError({ statusCode: 404, statusMessage: `Issue ${issueId} not found` })
+}
+
+// Shared preamble for the issue-mutation handlers: authenticated session, DB
+// handle, and the numeric `id` route param in one call. Handlers that need the
+// full row follow with loadIssueOr404(id).
+export async function requireEventContext(event: H3Event) {
+ const session = await requireUserSession(event)
+ const db = useDB()
+ const id = Number(getRouterParam(event, 'id'))
+ return { session, db, id }
+}
+
+// Load the full issue/solution row or 404. Unlike assertIssueExists (existence
+// only), callers here read further columns off the returned row.
+export async function loadIssueOr404(id: number) {
+ const issue = await useDB().query.issues.findFirst({ where: eq(issues.id, id) })
+ if (!issue) throw createError({ statusCode: 404, message: 'Issue not found' })
+ return issue
+}
diff --git a/server/utils/transform-issue.ts b/server/utils/transform-issue.ts
index 74ca9af..9c30f4c 100644
--- a/server/utils/transform-issue.ts
+++ b/server/utils/transform-issue.ts
@@ -52,6 +52,7 @@ export function transformIssue(issue: DbIssue, { includeModeration = false } = {
: null,
scale: issue.scale,
links: issue.links ?? null,
+ helpLabels: issue.helpLabels ?? [],
...(includeModeration && {
rejectionReason: issue.rejectionReason,
rejectedAt: issue.rejectedAt,
diff --git a/shared/types/umami.d.ts b/shared/types/umami.d.ts
index 5c46d89..0e92dd7 100644
--- a/shared/types/umami.d.ts
+++ b/shared/types/umami.d.ts
@@ -1,7 +1,7 @@
interface UmamiTracker {
track: {
(eventName: string): void
- (eventName: string, eventData: Record): void
+ (eventName: string, eventData: Record): void
}
identify: {
(uniqueId: string): void
diff --git a/tests/api/case-studies.test.ts b/tests/api/case-studies.test.ts
index a6285e7..2594068 100644
--- a/tests/api/case-studies.test.ts
+++ b/tests/api/case-studies.test.ts
@@ -2,10 +2,13 @@ import { describe, it, expect } from 'vitest'
import { apiFetch } from '../setup'
describe('GET /api/case-studies', () => {
- it('returns an array of approved case studies', async () => {
+ it('returns approved case studies with a degraded flag', async () => {
const res = await apiFetch('/api/case-studies')
- expect(Array.isArray(res)).toBe(true)
- for (const cs of res) {
+ expect(Array.isArray(res.items)).toBe(true)
+ expect(typeof res.degraded).toBe('boolean')
+ // No query was sent, so the recency list is the intended path.
+ expect(res.degraded).toBe(false)
+ for (const cs of res.items) {
expect(cs.status).toBe('approved')
expect(cs).toHaveProperty('outcome')
expect(cs).toHaveProperty('solutionId')
@@ -14,24 +17,24 @@ describe('GET /api/case-studies', () => {
it('filters by outcome', async () => {
const res = await apiFetch('/api/case-studies?outcome=success')
- expect(Array.isArray(res)).toBe(true)
- for (const cs of res) expect(cs.outcome).toBe('success')
+ expect(Array.isArray(res.items)).toBe(true)
+ for (const cs of res.items) expect(cs.outcome).toBe('success')
})
it('ignores an invalid outcome value (no filter applied)', async () => {
const res = await apiFetch('/api/case-studies?outcome=bogus')
- expect(Array.isArray(res)).toBe(true)
+ expect(Array.isArray(res.items)).toBe(true)
})
it('filters by verified', async () => {
const res = await apiFetch('/api/case-studies?verified=true')
- expect(Array.isArray(res)).toBe(true)
- for (const cs of res) expect(cs.verified).toBe(true)
+ expect(Array.isArray(res.items)).toBe(true)
+ for (const cs of res.items) expect(cs.verified).toBe(true)
})
it('respects the limit parameter', async () => {
const res = await apiFetch('/api/case-studies?limit=1')
- expect(Array.isArray(res)).toBe(true)
- expect(res.length).toBeLessThanOrEqual(1)
+ expect(Array.isArray(res.items)).toBe(true)
+ expect(res.items.length).toBeLessThanOrEqual(1)
})
})
diff --git a/tests/api/issue-detail.test.ts b/tests/api/issue-detail.test.ts
index 4626afc..5490ea3 100644
--- a/tests/api/issue-detail.test.ts
+++ b/tests/api/issue-detail.test.ts
@@ -20,11 +20,15 @@ describe('Issue Detail APIs', () => {
expect(issue).toHaveProperty('scale')
})
- it('includes moderation fields for detail view', async () => {
+ it('hides moderation fields from non-owner viewers', async () => {
const issue = await apiFetch('/api/issue/1')
- expect(issue).toHaveProperty('rejectionReason')
- expect(issue).toHaveProperty('isSpam')
+ // Moderation state (rejection reason, spam flag, appeal status…) is
+ // only exposed when the viewer is a node owner.
+ expect(issue).not.toHaveProperty('rejectionReason')
+ expect(issue).not.toHaveProperty('isSpam')
+ expect(issue).not.toHaveProperty('appealStatus')
+ expect(issue.viewerIsOwner).toBe(false)
})
it('returns empty for non-existent issue', async () => {
@@ -82,8 +86,7 @@ describe('Issue Detail APIs', () => {
body: JSON.stringify({ title: 'Test', description: 'Test description' }),
})
expect.unreachable()
- }
- catch (e: any) {
+ } catch (e: any) {
expect(e.statusCode).toBe(401)
}
})
@@ -97,8 +100,7 @@ describe('Issue Detail APIs', () => {
body: JSON.stringify({ reason: 'Test appeal' }),
})
expect.unreachable()
- }
- catch (e: any) {
+ } catch (e: any) {
expect(e.statusCode).toBe(401)
}
})
diff --git a/tests/api/issues.test.ts b/tests/api/issues.test.ts
index f975935..2726898 100644
--- a/tests/api/issues.test.ts
+++ b/tests/api/issues.test.ts
@@ -42,9 +42,17 @@ describe('GET /api/issues', () => {
}
})
- it('sorts by newest by default', async () => {
+ it('sorts by most_voted by default', async () => {
const issues = await apiFetch('/api/issues')
+ for (let i = 1; i < issues.length; i++) {
+ expect(issues[i - 1].voteScore >= issues[i].voteScore).toBe(true)
+ }
+ })
+
+ it('sorts by newest when requested', async () => {
+ const issues = await apiFetch('/api/issues?sort=newest')
+
for (let i = 1; i < issues.length; i++) {
expect(issues[i - 1].date >= issues[i].date).toBe(true)
}
diff --git a/tests/api/newsletter-unsubscribe.test.ts b/tests/api/newsletter-unsubscribe.test.ts
new file mode 100644
index 0000000..072545a
--- /dev/null
+++ b/tests/api/newsletter-unsubscribe.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest'
+
+const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'
+
+// The unsubscribe endpoint renders HTML (visitors arrive from an email
+// client), so this talks to fetch directly instead of apiFetch's JSON wrapper.
+async function getUnsubscribe(query: string) {
+ const res = await fetch(`${BASE_URL}/api/newsletter/unsubscribe${query}`)
+ return { status: res.status, body: await res.text() }
+}
+
+describe('GET /api/newsletter/unsubscribe', () => {
+ it('rejects a missing token with a branded 400 page', async () => {
+ const { status, body } = await getUnsubscribe('')
+ expect(status).toBe(400)
+ expect(body).toContain('not valid')
+ expect(body).toContain('CommunityFix')
+ })
+
+ it('rejects a garbage token', async () => {
+ const { status, body } = await getUnsubscribe('?token=not-a-real-token')
+ expect(status).toBe(400)
+ expect(body).toContain('not valid')
+ })
+
+ it('rejects a well-formed token with a bad signature', async () => {
+ const fake = `f2b9dc5e-3c1a-4a6d-9f6c-2f4f4de1a111.${'0'.repeat(64)}`
+ const { status } = await getUnsubscribe(`?token=${fake}`)
+ expect(status).toBe(400)
+ })
+
+ // A validly-signed token can only be minted with the server's session
+ // secret; when the test env shares it (doppler dev config), assert the
+ // idempotent happy path — unsubscribing an id with no prefs row still lands
+ // on the confirmation page.
+ it.skipIf(!process.env.NUXT_SESSION_PASSWORD)(
+ 'accepts a validly signed token even when no prefs row exists',
+ async () => {
+ const secret = process.env.NUXT_SESSION_PASSWORD!
+ const userId = 'f2b9dc5e-3c1a-4a6d-9f6c-2f4f4de1a111'
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ )
+ const sig = await crypto.subtle.sign(
+ 'HMAC',
+ key,
+ new TextEncoder().encode(`newsletter-unsub|${userId}`),
+ )
+ const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('')
+
+ const { status, body } = await getUnsubscribe(`?token=${userId}.${hex}`)
+ expect(status).toBe(200)
+ expect(body).toContain('unsubscribed')
+ },
+ )
+})
diff --git a/tests/api/onboarding-prefs.test.ts b/tests/api/onboarding-prefs.test.ts
new file mode 100644
index 0000000..fb6fb6e
--- /dev/null
+++ b/tests/api/onboarding-prefs.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest'
+import { apiFetch } from '../setup'
+
+// All onboarding/preferences endpoints are session-scoped: the public
+// contract is that anonymous callers get a 401 on every one of them.
+describe('Onboarding and preferences API', () => {
+ describe('GET /api/me/interests', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/interests')
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('POST /api/me/interests', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/interests', {
+ method: 'POST',
+ body: JSON.stringify({ label: 'beekeeping' }),
+ })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('DELETE /api/me/interests/:id', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/interests/1', { method: 'DELETE' })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('GET /api/tags/similar', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/tags/similar?q=pollinators')
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('GET /api/me/newsletter', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/newsletter')
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('PUT /api/me/newsletter', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/newsletter', {
+ method: 'PUT',
+ body: JSON.stringify({ enabled: true, frequency: 'monthly', content: {} }),
+ })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('POST /api/me/onboarded', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/me/onboarded', { method: 'POST' })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+})
diff --git a/tests/api/similar.test.ts b/tests/api/similar.test.ts
index 7c84d60..843d304 100644
--- a/tests/api/similar.test.ts
+++ b/tests/api/similar.test.ts
@@ -3,14 +3,18 @@ import { apiFetch } from '../setup'
describe('GET /api/issues/similar', () => {
it('returns too_short status when title is too short', async () => {
- const response = await apiFetch('/api/issues/similar?title=Hi&description=this%20is%20too%20short')
+ const response = await apiFetch(
+ '/api/issues/similar?title=Hi&summary=this%20summary%20is%20long%20enough',
+ )
expect(response.status).toBe('too_short')
expect(response.results).toEqual([])
})
- it('returns too_short status when description is too short', async () => {
- const response = await apiFetch('/api/issues/similar?title=A%20decent%20title%20here&description=short')
+ it('returns too_short status when summary is too short', async () => {
+ const response = await apiFetch(
+ '/api/issues/similar?title=A%20decent%20title%20here&summary=short',
+ )
expect(response.status).toBe('too_short')
expect(response.results).toEqual([])
@@ -25,7 +29,7 @@ describe('GET /api/issues/similar', () => {
it('returns ok or unavailable status with results array for a real query', async () => {
const response = await apiFetch(
- '/api/issues/similar?title=Reduce%20household%20waste%20and%20improve%20recycling&description=Every%20year%20millions%20of%20tons%20of%20waste%20end%20up%20in%20landfills',
+ '/api/issues/similar?title=Reduce%20household%20waste%20and%20improve%20recycling&summary=Every%20year%20millions%20of%20tons%20of%20waste%20end%20up%20in%20landfills',
)
expect(['ok', 'unavailable']).toContain(response.status)
@@ -35,7 +39,7 @@ describe('GET /api/issues/similar', () => {
const first = response.results[0]
expect(first).toHaveProperty('id')
expect(first).toHaveProperty('title')
- expect(first).toHaveProperty('description')
+ expect(first).toHaveProperty('summary')
expect(first).toHaveProperty('similarity')
expect(typeof first.similarity).toBe('number')
}
diff --git a/tests/api/wanted-skills.test.ts b/tests/api/wanted-skills.test.ts
new file mode 100644
index 0000000..576bc8d
--- /dev/null
+++ b/tests/api/wanted-skills.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest'
+import { apiFetch } from '../setup'
+
+describe('Wanted skills API', () => {
+ describe('GET /api/issue/:id/wanted-skills', () => {
+ it('returns an array for an existing issue', async () => {
+ const data = await apiFetch('/api/issue/1/wanted-skills')
+
+ expect(Array.isArray(data)).toBe(true)
+ for (const item of data) {
+ expect(item).toHaveProperty('id')
+ expect(item).toHaveProperty('skill')
+ expect(item).toHaveProperty('createdBy')
+ expect(item).toHaveProperty('createdById')
+ expect(item).toHaveProperty('createdAt')
+ }
+ })
+
+ it('returns 404 for non-existent issue', async () => {
+ try {
+ await apiFetch('/api/issue/99999/wanted-skills')
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(404)
+ }
+ })
+
+ it('returns 400 for invalid issue ID', async () => {
+ try {
+ await apiFetch('/api/issue/abc/wanted-skills')
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(400)
+ }
+ })
+ })
+
+ describe('POST /api/issue/:id/wanted-skills', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/issue/1/wanted-skills', {
+ method: 'POST',
+ body: JSON.stringify({ skill: 'GIS mapping' }),
+ })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+
+ describe('DELETE /api/issue/:id/wanted-skills/:skillId', () => {
+ it('requires authentication', async () => {
+ try {
+ await apiFetch('/api/issue/1/wanted-skills/1', {
+ method: 'DELETE',
+ })
+ expect.unreachable()
+ } catch (e: any) {
+ expect(e.statusCode).toBe(401)
+ }
+ })
+ })
+})
diff --git a/tests/unit/llms-txt.test.ts b/tests/unit/llms-txt.test.ts
index 2d34ed2..4edbfbd 100644
--- a/tests/unit/llms-txt.test.ts
+++ b/tests/unit/llms-txt.test.ts
@@ -58,7 +58,7 @@ function internalLinkPaths(): string[] {
/**
* Does Nuxt's file-based router have a *literal* (non-dynamic) page for these
* segments? Dynamic `[slug]` routes are intentionally excluded: a link to a
- * concrete path like `/guide/writing` should be backed by a concrete content
+ * concrete path like `/guide/authoring` should be backed by a concrete content
* file (checked separately), not merely satisfied by a catch-all `[slug].vue`.
*/
function staticPageResolves(segments: string[]): boolean {
diff --git a/tests/unit/newsletter-email.test.ts b/tests/unit/newsletter-email.test.ts
new file mode 100644
index 0000000..a7dbb87
--- /dev/null
+++ b/tests/unit/newsletter-email.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest'
+import { renderNewsletterEmail } from '../../server/utils/newsletter-email'
+import type { DigestSection } from '../../server/utils/newsletter-digest'
+
+const sections: DigestSection[] = [
+ {
+ key: 'topicMatches',
+ title: 'New in your interests',
+ items: [
+ {
+ title: 'Urban heat islands',
+ url: 'https://communityfix.org/issue/42',
+ summary: 'Cities are getting hotter.',
+ meta: 'matches “climate adaptation”',
+ },
+ ],
+ },
+ {
+ key: 'helpWanted',
+ title: 'Help wanted',
+ items: [
+ {
+ title: 'Community composting',
+ url: 'https://communityfix.org/issue/7',
+ summary: 'Needs a cost baseline.',
+ },
+ ],
+ footerLink: { label: 'See everything', url: 'https://communityfix.org/contribute' },
+ },
+]
+
+const base = {
+ name: 'Ada',
+ frequency: 'weekly' as const,
+ sections,
+ unsubscribeUrl: 'https://communityfix.org/api/newsletter/unsubscribe?token=abc.def',
+}
+
+describe('renderNewsletterEmail', () => {
+ it('renders every section with items, links, and the footer', () => {
+ const { subject, html, text } = renderNewsletterEmail(base)
+
+ expect(subject).toBe('Your weekly digest — CommunityFix')
+ expect(html).toContain('Hi Ada')
+ expect(html).toContain('New in your interests')
+ expect(html).toContain('https://communityfix.org/issue/42')
+ expect(html).toContain('Urban heat islands')
+ expect(html).toContain('See everything')
+ expect(html).toContain(base.unsubscribeUrl)
+ expect(html).toContain('https://communityfix.org/settings')
+
+ // Plaintext mirrors the content.
+ expect(text).toContain('NEW IN YOUR INTERESTS')
+ expect(text).toContain('- Urban heat islands')
+ expect(text).toContain('https://communityfix.org/issue/7')
+ expect(text).toContain(`Unsubscribe: ${base.unsubscribeUrl}`)
+ })
+
+ it('adapts subject and intro to the frequency and copes with a missing name', () => {
+ const { subject, html } = renderNewsletterEmail({
+ ...base,
+ name: null,
+ frequency: 'monthly',
+ })
+ expect(subject).toBe('Your monthly digest — CommunityFix')
+ // No name on file → neutral greeting, and the intro reflects the period.
+ expect(html).toContain('Hello')
+ expect(html).toContain('Here is what happened on the commons this month.')
+ })
+
+ it('escapes user-authored content everywhere it lands in the HTML', () => {
+ const { html } = renderNewsletterEmail({
+ ...base,
+ name: 'Ada ',
+ sections: [
+ {
+ key: 'goodNews',
+ title: 'Good news',
+ items: [
+ {
+ title: '',
+ url: 'https://communityfix.org/case-study/1" onmouseover="x',
+ summary: 'A & B ',
+ meta: 'Berlin · "quoted"',
+ },
+ ],
+ },
+ ],
+ })
+ expect(html).not.toContain('')
+ expect(html).toContain('<script>')
+ expect(html).not.toContain('Ada ')
+ expect(html).toContain('A & B <img src=x>')
+ expect(html).toContain('"quoted"')
+ // The href attribute can't be broken out of either.
+ expect(html).toContain('case-study/1" onmouseover="x')
+ })
+})
diff --git a/tests/unit/newsletter-token.test.ts b/tests/unit/newsletter-token.test.ts
new file mode 100644
index 0000000..8410bb0
--- /dev/null
+++ b/tests/unit/newsletter-token.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest'
+
+// newsletter-token reads the session secret through the Nitro
+// `useRuntimeConfig` / `createError` globals; stub both so the helpers run
+// under plain vitest (same approach as rate-limit.test.ts).
+vi.stubGlobal('useRuntimeConfig', () => ({
+ session: { password: 'test-secret-at-least-32-chars!!' },
+}))
+vi.stubGlobal('createError', (opts: { statusCode?: number; statusMessage?: string }) =>
+ Object.assign(new Error(opts.statusMessage ?? 'error'), opts),
+)
+
+const { issueUnsubscribeToken, verifyUnsubscribeToken } =
+ await import('../../server/utils/newsletter-token')
+
+const USER_ID = 'f2b9dc5e-3c1a-4a6d-9f6c-2f4f4de1a111'
+
+describe('newsletter unsubscribe tokens', () => {
+ it('round-trips: a freshly issued token verifies to its user id', async () => {
+ const token = await issueUnsubscribeToken(USER_ID)
+ expect(token.startsWith(`${USER_ID}.`)).toBe(true)
+ expect(await verifyUnsubscribeToken(token)).toBe(USER_ID)
+ })
+
+ it('rejects tampered tokens', async () => {
+ const token = await issueUnsubscribeToken(USER_ID)
+ // Flip the user id but keep the signature.
+ const otherId = 'a2b9dc5e-3c1a-4a6d-9f6c-2f4f4de1a222'
+ const forged = `${otherId}.${token.split('.')[1]}`
+ expect(await verifyUnsubscribeToken(forged)).toBeNull()
+ // Flip one signature character.
+ const sig = token.split('.')[1]!
+ const flipped = `${USER_ID}.${sig.slice(0, -1)}${sig.endsWith('0') ? '1' : '0'}`
+ expect(await verifyUnsubscribeToken(flipped)).toBeNull()
+ })
+
+ it('rejects malformed input', async () => {
+ expect(await verifyUnsubscribeToken('')).toBeNull()
+ expect(await verifyUnsubscribeToken('no-dot')).toBeNull()
+ expect(await verifyUnsubscribeToken('.only-sig')).toBeNull()
+ expect(await verifyUnsubscribeToken(`${USER_ID}.`)).toBeNull()
+ })
+})
diff --git a/vize.config.ts b/vize.config.ts
index 5d0b824..06963de 100644
--- a/vize.config.ts
+++ b/vize.config.ts
@@ -20,5 +20,17 @@ export default defineConfig({
tabWidth: 2,
trailingComma: 'all',
},
- ignores: ['.nuxt', '.nitro', '.output', '.data', '.wrangler', 'dist', 'node_modules'],
+ ignores: [
+ '.nuxt',
+ '.nitro',
+ '.output',
+ '.data',
+ '.wrangler',
+ 'dist',
+ 'node_modules',
+ // Drizzle owns these snapshot/journal JSON files and rewrites them in its
+ // own format on every db:generate — leave them alone so a new migration
+ // doesn't trip fmt --check.
+ 'server/database/migrations',
+ ],
})
diff --git a/workers/moderation/src/help-labels.ts b/workers/moderation/src/help-labels.ts
new file mode 100644
index 0000000..b3f0920
--- /dev/null
+++ b/workers/moderation/src/help-labels.ts
@@ -0,0 +1,98 @@
+// Deterministic "help wanted" labelling. Given a node's structured fields, work
+// out which evidence/quality gaps it has. Pure and LLM-free: the moderation
+// pipeline runs this after enrichment (curate + location resolution) has settled
+// the text and location, then persists the result on `help_labels`.
+//
+// Labels are NOT rendered on the node itself. They power the contribute view,
+// where users filter by label to find nodes worth improving.
+import type {
+ HelpLabel,
+ IssueType,
+ CaseStudyOutcome,
+ LocationScale,
+} from '../../../server/database/schema'
+
+// A citation is worth counting only if it actually links somewhere.
+type Linkish = { url?: string | null } | null | undefined
+function hasLinks(arr: Linkish[] | null | undefined): boolean {
+ return (
+ Array.isArray(arr) &&
+ arr.some((l) => !!l && typeof l.url === 'string' && l.url.trim().length > 0)
+ )
+}
+
+// Markdown inline link or a bare URL inside a description body.
+const INLINE_CITATION = /\]\((https?:\/\/[^)]+)\)|https?:\/\/\S+/i
+// A quantitative claim: a number joined to a unit, percentage, currency, or
+// magnitude word. Presence of one of these with no citation is what makes a node
+// "needs-sources" — an unsourced statistic reads as an assertion, not evidence.
+const QUANT_CLAIM =
+ /\d[\d,.]*\s?(%|percent|pp|per cent|million|billion|thousand|tonnes?|tons?|kg|km|ha|hectares?|deaths?|injur|cases?|USD|EUR|GBP|\$|€|£|R\d)/i
+
+export interface IssueNodeFields {
+ type: IssueType
+ description: string | null
+ scale: LocationScale | null
+ location: unknown | null
+ links: Array<{ url: string; title?: string }> | null
+}
+
+export interface CaseStudyNodeFields {
+ outcome: CaseStudyOutcome
+ description: string | null
+ metrics: Array<{ label: string; baseline?: string; result?: string; unit?: string }> | null
+ sources: Array<{ url: string; title?: string }> | null
+ cost: string | number | null
+}
+
+const nonEmpty = (s: string | null | undefined) => !!s && s.trim().length > 0
+
+// Scales that describe a bounded place and therefore benefit from a point. A
+// `global` node legitimately has no coordinate, so it is never asked for one.
+const LOCATABLE_SCALES: LocationScale[] = ['neighborhood', 'city', 'region', 'national']
+
+export function computeIssueHelpLabels(node: IssueNodeFields): HelpLabel[] {
+ const labels: HelpLabel[] = []
+ const desc = node.description ?? ''
+
+ // needs-sources: the body makes a quantitative claim but cites nothing (neither
+ // an inline link nor, for solutions, a `links` entry). Issues have no links
+ // field, so they rely entirely on inline citation.
+ if (QUANT_CLAIM.test(desc) && !INLINE_CITATION.test(desc) && !hasLinks(node.links)) {
+ labels.push('needs-sources')
+ }
+
+ // needs-location: a bounded-scale node with no resolved coordinate. Checked
+ // after the pipeline's location-resolution pass, so this only fires when
+ // resolution genuinely found nothing.
+ if (node.scale && LOCATABLE_SCALES.includes(node.scale) && node.location == null) {
+ labels.push('needs-location')
+ }
+
+ return labels
+}
+
+export function computeCaseStudyHelpLabels(node: CaseStudyNodeFields): HelpLabel[] {
+ const labels: HelpLabel[] = []
+ const metrics = Array.isArray(node.metrics) ? node.metrics : []
+ const hasMetrics = metrics.length > 0
+ const claimsOutcome =
+ node.outcome === 'success' || node.outcome === 'partial' || node.outcome === 'failed'
+
+ // needs-evidence: a study asserting a concrete outcome with no metrics at all.
+ // ongoing / inconclusive are exempt — interim or unresolved studies may not
+ // have numbers yet.
+ if (claimsOutcome && !hasMetrics) labels.push('needs-evidence')
+
+ // needs-baseline: metrics exist but none carry a before/baseline value, so the
+ // reader cannot see the change the intervention produced.
+ if (hasMetrics && !metrics.some((m) => nonEmpty(m.baseline))) labels.push('needs-baseline')
+
+ // needs-sources: no citations backing the claims.
+ if (!hasLinks(node.sources)) labels.push('needs-sources')
+
+ // needs-cost: no cost recorded, so reproducibility-by-budget can't be judged.
+ if (node.cost == null || String(node.cost).trim().length === 0) labels.push('needs-cost')
+
+ return labels
+}
diff --git a/workers/moderation/src/index.ts b/workers/moderation/src/index.ts
index f522162..16f75d8 100644
--- a/workers/moderation/src/index.ts
+++ b/workers/moderation/src/index.ts
@@ -20,6 +20,8 @@ import {
applyCaseStudyCurate,
resolveLocation,
applyLocationFix,
+ labelIssue,
+ labelCaseStudy,
prepareRevision,
prescreenRevision,
applyRevisionPrescreen,
@@ -119,6 +121,9 @@ export class ModerationWorkflow extends WorkflowEntrypoint labelIssue(ctx, id))
await this.reviewStructure(ctx, step, id)
}
@@ -226,6 +231,9 @@ export class ModerationWorkflow extends WorkflowEntrypoint finalizeCaseStudy(ctx, prep, moderation))
await this.enrichCaseStudy(ctx, step, prep)
+ // Curate may have stripped fields; compute help-wanted labels from the
+ // settled state. Best-effort — never blocks the (already committed) approval.
+ await step.do('label', STEP, () => labelCaseStudy(ctx, id))
}
private async enrichCaseStudy(ctx: Ctx, step: WorkflowStep, prep: CaseStudyPrep) {
diff --git a/workers/moderation/src/pipelines.ts b/workers/moderation/src/pipelines.ts
index c4e4175..7db80a2 100644
--- a/workers/moderation/src/pipelines.ts
+++ b/workers/moderation/src/pipelines.ts
@@ -19,8 +19,10 @@ import {
checkAndApplyBan,
} from './lib'
import { STEPS, runAgent, runStep } from './steps'
+import { computeIssueHelpLabels, computeCaseStudyHelpLabels } from './help-labels'
import { createGeocodeTool, bboxToPolygon } from './geocode'
import { areaSimplifyTolerance, simplifyAreaGeometry } from '../../../server/utils/simplify-geo'
+import { adjustParentCounter } from '../../../server/utils/issue-counters'
const DUPLICATE_THRESHOLD = 0.92
// Case studies sit under a single solution and are thematically close by
@@ -231,13 +233,7 @@ export async function finalizeIssue(
}
if (!moderation.approved) {
- if (issue.parentId) {
- const counter =
- issue.type === 'solution'
- ? { solutionCount: sql`GREATEST(${issues.solutionCount} - 1, 0)` }
- : { subIssueCount: sql`GREATEST(${issues.subIssueCount} - 1, 0)` }
- await db.update(issues).set(counter).where(eq(issues.id, issue.parentId))
- }
+ await adjustParentCounter(db, issue, -1)
await db
.update(issues)
.set({
@@ -404,13 +400,7 @@ export async function prepareStructure(ctx: Ctx, issueId: number): Promise {
await tx.update(issues).set({ parentId: verdict.targetId }).where(eq(issues.id, issueId))
- await tx.update(issues).set(counter).where(eq(issues.id, verdict.targetId!))
+ await adjustParentCounter(tx, { parentId: verdict.targetId, type: issue.type }, 1)
})
await createAuditLog(db, {
type: 'structure',
@@ -1029,6 +1015,67 @@ export async function applyCaseStudyCurate(
console.log(`[review-case-study] Case study ${caseStudyId} curated`)
}
+// ── Help-wanted labelling ───────────────────────────────────────────────────
+// Deterministic, LLM-free. Runs at the tail of the pipeline, after enrichment
+// has settled the curated text and any resolved location, so the gap checks see
+// final state. Best-effort: a failure here never rolls back an approval.
+
+function sameLabels(a: readonly string[], b: readonly string[]): boolean {
+ if (a.length !== b.length) return false
+ const set = new Set(a)
+ return b.every((x) => set.has(x))
+}
+
+export async function labelIssue(ctx: Ctx, issueId: number): Promise {
+ const { db } = ctx
+ const node = await db.query.issues.findFirst({ where: eq(issues.id, issueId) })
+ if (!node || node.status !== 'approved') return
+
+ const next = computeIssueHelpLabels({
+ type: node.type,
+ description: node.description,
+ scale: node.scale,
+ location: node.location,
+ links: node.links,
+ })
+ if (sameLabels(node.helpLabels ?? [], next)) return
+
+ await db.update(issues).set({ helpLabels: next }).where(eq(issues.id, issueId))
+ await createAuditLog(db, {
+ type: 'moderation',
+ action: 'label',
+ issueId,
+ userId: node.authorId,
+ details: { helpLabels: next, previous: node.helpLabels ?? [] },
+ })
+ console.log(`[review-issue] Issue ${issueId} help-labels: [${next.join(', ')}]`)
+}
+
+export async function labelCaseStudy(ctx: Ctx, caseStudyId: number): Promise {
+ const { db } = ctx
+ const cs = await db.query.caseStudies.findFirst({ where: eq(caseStudies.id, caseStudyId) })
+ if (!cs || cs.status !== 'approved') return
+
+ const next = computeCaseStudyHelpLabels({
+ outcome: cs.outcome,
+ description: cs.description,
+ metrics: cs.metrics,
+ sources: cs.sources,
+ cost: cs.cost,
+ })
+ if (sameLabels(cs.helpLabels ?? [], next)) return
+
+ await db.update(caseStudies).set({ helpLabels: next }).where(eq(caseStudies.id, caseStudyId))
+ await createAuditLog(db, {
+ type: 'moderation',
+ action: 'label',
+ issueId: cs.solutionId,
+ userId: cs.authorId,
+ details: { caseStudyId, helpLabels: next, previous: cs.helpLabels ?? [] },
+ })
+ console.log(`[review-case-study] Case study ${caseStudyId} help-labels: [${next.join(', ')}]`)
+}
+
// ── Revision pre-screen ────────────────────────────────────────────────────
// A lightweight, single-shot triage of a *proposed* collaborative edit (the
// `revisions` table) before its node owner reviews it. It does NOT moderate the