diff --git a/README.md b/README.md index a90a6f4..8dadee8 100644 --- a/README.md +++ b/README.md @@ -1 +1,72 @@ -# OGCR-App \ No newline at end of file +# OGCR-App + +A web interface for the OGCR carbon registry. It talks to the registry (the DCR) +through the [Open Bank Project](https://www.openbankproject.com) API and its dynamic +entities — the registry owns the carbon-activity data; this app presents it, and lets +operators list their activities on the marketplace. + +Built with SvelteKit 5, Skeleton 4 and Tailwind v4. + +## Requirements + +- Node 22 +- Redis (session store) +- An OBP API instance, and an OIDC provider for login + +## Getting started + +```bash +cp .env.example .env # then fill in OBP URL, OAuth client, SESSION_SECRET +npm install +npm run dev # http://localhost:5200 +``` + +`.env.example` documents every variable, which are required, and which are optional. +`SESSION_SECRET` must be set (`openssl rand -hex 32`). + +Alternatively, `docker compose up` builds the app and starts Redis alongside it on port +3000; it reads the same variables from the environment. + +## Commands + +| Command | What it does | +| --------------------------------- | --------------------------------------------- | +| `npm run dev` | Dev server on port 5200 | +| `npm run build` | Production build (`@sveltejs/adapter-node`) | +| `npm run preview` | Serve the production build | +| `npm run check` | `svelte-check` over the project | +| `npm run check:design-tokens` | Check mirrored design tokens against upstream | +| `npm run test:unit -- --run` | Unit tests (vitest) | +| `npm run test:e2e` | End-to-end tests (Playwright) | +| `npm run test` | Both suites | +| `npm run lint` / `npm run format` | Prettier check / write | + +## Design system tokens + +This app cannot import the [OGCR Design System](https://github.com/Maji-Studio/ogcr) +components — it is React, this app is Svelte — so `src/ogcr-design-system-reference.css` +mirrors its token _values_ by hand and `src/ogcr-theme.css` maps them onto Skeleton's +scale. A hand copy rots silently, so check it before design work and whenever the design +system releases: + +```bash +npm run check:design-tokens +``` + +It diffs all 92 tokens against the published npm package (no clone needed) and exits 0 in +sync, 1 on drift, 2 if it could not check. It is deliberately not part of `npm run build`, +which would then need network access. + +If it reports drift, see +[design_system_integration.md](./design_system_integration.md) — that also covers what is +pinned to which upstream commit, and which components have been ported to Svelte. + +## Docs + +| | | +| -------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [design_goals.md](./design_goals.md) | Why the marketplace lists registry activities rather than creating them | +| [design_system_integration.md](./design_system_integration.md) | How this app consumes the OGCR Design System | +| [chain_integration.md](./chain_integration.md) | How on-chain state reaches the app, and the heartbeat | +| [open_questions.md](./open_questions.md) | Unresolved design and product questions | +| [CLAUDE.md](./CLAUDE.md) | Notes on discovering OBP dynamic entity endpoints | diff --git a/chain_integration.md b/chain_integration.md index 2500a30..ab45982 100644 --- a/chain_integration.md +++ b/chain_integration.md @@ -251,10 +251,15 @@ It appears in two places: - **The landing page**, compact, so the connection is visible without anyone going looking for it. -- **`/chain`**, in full, with per-entity counts from the last run and a recent - chain activity table ordered by block. That page re-reads itself on the - mirror's own cadence, since a heartbeat that only updates on reload is not - much of a heartbeat. +- **`/chain`**, in full, with per-entity counts from the last run, the time of + that pass, and a recent chain activity table ordered by block. That page + re-reads itself on the mirror's own cadence, since a heartbeat that only + updates on reload is not much of a heartbeat. + +Timestamps render as UTC on the server and switch to the reader's own time zone +once mounted. Formatting a date the obvious way would render server-local time +and then be replaced during hydration, which is a mismatch; emitting a +deterministic UTC string first avoids it. The recent activity table carries a note explaining that a short list with an old newest block is a quiet chain rather than a broken one, so the two signals diff --git a/design_system_integration.md b/design_system_integration.md new file mode 100644 index 0000000..ce14bc0 --- /dev/null +++ b/design_system_integration.md @@ -0,0 +1,259 @@ +# OGCR Design System integration + +How this app consumes the OGCR Design System: why it hand-mirrors the token values +instead of installing the package, what is pinned to which upstream commit, how to +re-reconcile when upstream moves, and which components have been ported to Svelte. + +This is a record of _our consumption_ of the design system. It is not the design +system's specification — that lives upstream at +`packages/design-system/docs/design-system.md` and is the authority. Where this +document and the upstream spec disagree about the design system, the spec wins. + +## The short version + +The OGCR Design System is a **React 19 + Base UI + Tailwind v4** component library. +This app is **SvelteKit 5 + Skeleton 4 + Tailwind v4**. None of its 42 components can be +imported here. So we mirror its **token values** by hand and build the Svelte components +ourselves against the published spec. + +Everything in this document exists so that mirroring is a _checkable_ operation rather +than a copy that silently rots — which is exactly what happened to the previous copy +(see the [drift log](#drift-log)). + +## Upstream provenance (what we are pinned to) + +| | | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Repo | `https://github.com/Maji-Studio/ogcr` | +| Remote (SSH) | `git@github.com:Maji-Studio/ogcr.git` | +| Local clone | `~/Documents/workspace_2024/Maji-Studio/ogcr` | +| Branch | `main` | +| Reconciled against commit | `182d425e76ce204826c9b2440930b5a8f7d02094` (`182d425`, 2026-09-07) | +| Package | `@majistudio/ogcr-design-system` **1.1.0** (also on npm) | +| Token source files | `packages/design-system/src/styles/palette.css` (all 62 colour literals)
`packages/design-system/src/styles/theme.css` (spacing / radius / type / elevation / motion) | +| Written spec | `packages/design-system/docs/design-system.md` | +| Last upstream commit that changed those tokens | `e9471331f60bec1d76c91198771be4f392602cb7` (`e947133`, 2026-08-13) — _"feat(ds): align tokens to figma and ship the full stylesheet contract"_ | +| Figma source of truth | file `2P6XrQJhT8I39IR5LGK7RT` (OGCR – Design System); upstream last reconciled it 2026-08-13 | + +**The token pin that matters is `e947133`, not `182d425`.** `182d425` is just the repo HEAD +we happened to read. If `git log e947133..main -- packages/design-system/src/styles/` is +empty, our tokens are current no matter how far HEAD has moved. + +### Predecessor repo — do not use + +`https://github.com/Maji-Studio/ogcr-design-system` is **archived** and frozen at 1.0.0. +Its README points at the monorepo. Its Vercel demo and Storybook are frozen too. A local +clone may still exist at `~/Documents/workspace_2024/OGCR-design/ogcr-design-system`; +treat it as historical only. + +## Why we do not just install the package + +Installing `@majistudio/ogcr-design-system` and importing its `styles.css` is technically +possible in a Tailwind v4 app, and it would give us exact tokens. We are not doing it yet, +for one specific reason documented in the upstream spec (§1, _Consuming the shipped +stylesheet_): + +> **`--spacing` is pinned to `1px` globally.** […] Stock Tailwind sets `.25rem` […] +> **This rescales existing app markup that used the stock 4px-step scale: `p-4` is 4px +> now, not 16px.** Sweep numeric spacing classes during adoption. + +This app sets `--spacing: 0.25rem` (`src/ogcr-theme.css`) and uses **1,192 numeric +spacing/size classes across 37 of its 41 Svelte files**. Importing the stylesheet shrinks +every one of them to a quarter size. That is an app-wide restyling sweep, not a +token refresh, and it cannot be scoped to one route — `--spacing` lives in `@theme` at +the root. + +Two further behaviour changes from the same section, for whenever we do adopt it: + +- `--font-size-m` moved 16 → 18px, taking `--text-h4` and `--text-body` with it. Body copy + and h4 grow 2px system-wide. +- `shadow-elevation-s` never existed. If anything here uses it, it renders nothing. + +## What we mirror, and what we deliberately do not + +**`src/ogcr-design-system-reference.css`** — the mirror. Named DS tokens at their exact +upstream values. It is reference/documentation; nothing imports it. + +**`src/ogcr-theme.css`** — the active theme. Two distinct things live here: + +1. _The named DS token block_ — a 1:1 mirror of upstream values. Keep exact. +2. _The Skeleton `--color-_`ramps* — **derived, not mirrored.** These are interpolated +11-step ramps with DS values pinned at particular steps (e.g.`--color-secondary-400`is`brand-green-500`, `-500`is`interaction-primary-default`). The in-between steps + have no upstream counterpart and are ours. Do not "reconcile" them against upstream; + only the anchor steps need to agree. + + One consequence as of 1.1.0: `--color-secondary-600` / `--color-success-600` + (`#416c51`) no longer correspond to _any_ DS token — `interaction-primary-hover` moved + away from that value. They remain valid ramp steps. Code that wants the true brand + hover must use `var(--interaction-primary-hover)`, not `secondary-600`. + +## Drift log + +### 2026-09-22 — first reconciliation + +The previous mirror claimed to be a _"verbatim copy of +OGCR-design/ogcr-design-system/src/index.css captured 2026-05-04."_ That file no longer +exists upstream — it was split into `palette.css` + `theme.css`. Diffing the mirror +against the real palette found, of 62 upstream colour tokens: **38 identical, 5 drifted, +19 missing.** + +Drifted values (old → correct): + +| Token | Was | Now | Note | +| ------------------------------- | --------- | --------- | -------------------------------------- | +| `--text-positive` | `#416c51` | `#4f8263` | | +| `--icon-positive` | `#416c51` | `#4f8263` | | +| `--interaction-primary-hover` | `#416c51` | `#335641` | | +| `--interaction-primary-active` | `#416c51` | `#335641` | | +| `--interaction-secondary-focus` | `#e2d0bf` | `#c3daed` | a beige where the brand has light blue | + +The green moves are upstream's deliberate _active-green vs. selection-navy_ split +(`68073dd` in the archived repo). + +Added (were missing): `yellow-300`, `text-progress`, `icon-progress`, the four `icon-*-light` +tokens, `surface-progress`, `border-neutral-strong`, `border-positive-strong`, +`border-warning-strong`, the four `interaction-secondary-*` tokens, +`interaction-tertiary-active`, `scrollbar-track`, `scrollbar-thumb`, `z-overlay`, `z-toast`. + +Non-colour drift found in the same pass: + +- **Type ladder.** Upstream is 10 / 14 / 18 / 20 / 24 / 32 / 40 / 48 / 64. Ours had + `--font-size-xs: 14px` and `--font-size-s: 16px`; 16px is not on the ladder at all. + Corrected to `xs: 10px`, `s: 14px`. **This is the one change here that can reflow + existing markup** — anything using `--font-size-xs`/`-s` directly gets smaller. +- **`--radius-full`** was `999px`; upstream is `9999px`. +- **`--focus-ring-error` was removed upstream.** Per the spec: _"It existed, referenced + nothing, and was removed; use `--focus-error` (the full shadow) instead."_ Ours is kept + for now, marked deprecated, because app code still references it. + +## How to check whether upstream has changed + +```bash +npm run check:design-tokens +``` + +Fetches the published tarball for the pinned version and diffs every token against +`src/ogcr-design-system-reference.css` — all 62 `--ds-*` colours from `dist/styles.css`, +plus the spacing, radius, type, elevation, motion and font-family scales from +`dist/theme.css`. Exit 0 in sync, 1 on drift, 2 if it could not check (offline). + +It reads the **published npm package**, not a clone, so it needs no checkout and no +machine-specific path — and it compares against the artifact we would actually consume +if we ever adopt the stylesheet directly. It also prints a note when a newer version +than our pin exists; `--latest` checks against that version instead. + +The script normalises what the minified stylesheet does to values: `#fff` back to +`#ffffff`, `var(--ds-x)` to our bare `var(--x)`, unitless `0` to `0px`, and upstream's +`rem` type scale to our `px`. The renamed scales are compared through an explicit map +(`--space-m` ↔ `--spacing-16`, `--font-size-s` ↔ `--text-s`, and so on) which lives at +the top of the script. + +### When it reports drift + +Update all four together, or the next reader gets a different answer depending on which +file they open: + +1. `src/ogcr-design-system-reference.css` — the mirror +2. `src/ogcr-theme.css` — the _named DS token block_ only, not the Skeleton ramps +3. `src/routes/design/+page.svelte` — the gallery hardcodes the values as display strings, + and its _Integration status_ section restates the provenance block and drift log +4. this document — the provenance block, and a row in the drift log below + +Then bump `PINNED_VERSION` in the script. + +### Checking for unreleased changes + +The script tracks published releases. To see whether something is brewing upstream that +has not been published yet, use the clone: + +```bash +cd ~/Documents/workspace_2024/Maji-Studio/ogcr +git fetch && git log e947133..origin/main -- packages/design-system/src/styles/ +``` + +Empty output means nothing has touched the token sources since our pin. + +## Component port status + +We build these in Svelte ourselves. Upstream section numbers refer to +`packages/design-system/docs/design-system.md`. The table below is also rendered on +`/design`, under _Component port status_, with the ported components shown live — keep +the two in step. + +| Upstream component | Spec | Status here | +| ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Card | §4.3 | **ported** — `src/lib/components/Card.svelte` | +| Pill | §4.6 | **ported** — `src/lib/components/Pill.svelte` | +| Message | §4.9 | not needed — the app's `ObpErrorDisplay` already handles OBP errors (it detects missing-entitlement messages and renders `MissingRoleAlert`), which DS Message would not | +| KPI | §4.11 | **ported** — `src/lib/components/Kpi.svelte` | +| Table | §4.14 | **ported** — `src/lib/components/Table.svelte`. Upstream is `@tanstack/react-table`; sorting and the empty state are reimplemented in Svelte, all visual decisions transcribed | +| Select / Combobox | §4.17 / §4.18 | not yet ported — build on `bits-ui` | +| Tabs | §4.23 | not yet ported — build on `bits-ui` | +| Breadcrumb | §4.26 | not needed — see note below | +| Pagination | §4.27 | not yet ported | +| Tooltip | §4.34 | not yet ported — build on `bits-ui` | + +The app layout renders a breadcrumb trail in the header for **every** route, built +from the URL path against a `labelMap` in `src/routes/+layout.svelte`. A page-level +Breadcrumb on top of that reads as a duplicate, so §4.26 is deliberately not ported. +Add new route segments to that `labelMap` instead, or the trail renders the raw +lowercase segment. Port the component only if a page needs a breadcrumb _instead of_ +the header trail. + +### Porting conventions + +`Card.svelte` is the reference for the ones that follow. + +- **Take values from the shipped component, not the spec's CSS** (see the trap below). +- **Express them as scoped CSS over our named tokens.** Upstream components are Tailwind + utility strings (`bg-surface-light`, `rounded-16`); those utilities do not exist in this + app, because we mirror token values rather than importing the design system's stylesheet. + The declarations are the same either way. +- **Namespace the class names `ogcr-`.** Skeleton ships a global `.card`, and Svelte's + style scoping does not stop _its_ rules from landing on our elements — only ours from + leaking out. +- **Dark mode is ours, not theirs.** The design system has no dark palette and its light + tokens are fixed values, so a component styled purely from them stays light on a dark + page. Add a `:global([data-mode='dark'])` block falling back to the Skeleton dark + surfaces the rest of the app uses. When upstream ships a dark palette, those blocks + should be replaced by `--ds-*` overrides rather than extended. +- **Document any API extension** in the component header. `Card` has two: `href` (renders + as an anchor so the whole card is one click target) and a `leading` snippet for an icon. + +### A trap in the spec + +`docs/design-system.md` §4.1–4.14 shows copy-paste CSS using `ogcr-*` BEM class names +(`.ogcr-card`, `.ogcr-table__th`). **Those classes exist nowhere in the shipped source** — +`grep -r "ogcr-card" packages/design-system/src/` returns nothing. The real components are +Tailwind utility strings with `data-slot` anatomy (e.g. Card is +`flex flex-col gap-16 p-16 bg-surface-light border border-border-medium rounded-16`). + +This survives upstream's own _"reconcile the spec with figma and code"_ pass (`d345312`), +so it is live in 1.1.0. Upstream's `CLAUDE.md` says the spec wins over code on +disagreement; for this particular disagreement it should not. **Read the component source, +use the doc for behaviour, anatomy and rationale.** + +## Appendix: on porting this app to React + +Not planned. Recorded because the question keeps coming up. + +The case for it is narrow and real: the design system is React and will stay React, so in +Svelte we hand-port all 42 components and hand-port every future release — plus TanStack +Table, which the registry table leans on. Upstream `apps/farmer-prototype` (Next.js 16 + +React 19) is a working reference for consuming the DS, and the monorepo is shaped to take +a second app. + +The case against: ~6,600 lines of Svelte markup across 41 components, plus the pieces with +no mechanical mapping — `svelte-kit-sessions` + Redis + arctic OAuth (farmer uses +better-auth), form actions, `adapter-node`. The markup is mechanical; the auth flow is not, +because verifying it means round-trips against a live OBP server. + +What would _not_ need rewriting: `src/lib/` — roughly 4,470 lines of framework-free +TypeScript (`obp/`, `chain/`, `oauth/`, `marketplace/`, `reference/`, `health-check/`). +Only three files there import anything SvelteKit-specific. + +Note also that `farmer-prototype` is not a backend we could inherit: its only tables are +Better Auth's plus generic `projects`/`items`, it defaults to `MOCK_DATA=true` with an +in-memory store, and every carbon-domain surface in it (programs, dashboard, the 2,097-line +scope-3 flow) is hardcoded TypeScript constants. This app has considerably more real +domain logic than it does. diff --git a/package.json b/package.json index d614a43..d1ee3be 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "check:design-tokens": "node scripts/check-design-tokens.mjs", "format": "prettier --write .", "lint": "prettier --check .", "test:e2e": "playwright test", diff --git a/scripts/check-design-tokens.mjs b/scripts/check-design-tokens.mjs new file mode 100755 index 0000000..9338661 --- /dev/null +++ b/scripts/check-design-tokens.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * Check our mirrored OGCR Design System tokens against the published package. + * + * This app cannot import the design system's components (it is React, we are + * Svelte), so src/ogcr-design-system-reference.css mirrors its token VALUES by + * hand. A hand copy rots silently — this script is what makes it checkable. + * Background: ../design_system_integration.md + * + * Source of truth is the published npm tarball, not a local clone: it needs no + * checkout, no machine-specific path, and it is the artifact we would actually + * consume if we ever adopt the stylesheet directly. dist/styles.css carries all + * 62 `--ds-*` color tokens; dist/theme.css carries the spacing/radius/type scales. + * + * node scripts/check-design-tokens.mjs # check the pinned version + * node scripts/check-design-tokens.mjs --latest # check against latest instead + * + * Exit codes: 0 in sync · 1 drift found · 2 could not check (network/tooling). + */ + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE = '@majistudio/ogcr-design-system'; + +/** The version this app's mirror is reconciled against. Bump it (and the mirror, + * and the doc's provenance block) together, never separately. */ +const PINNED_VERSION = '1.1.0'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const MIRROR = join(ROOT, 'src/ogcr-design-system-reference.css'); + +/** Our token name -> upstream token name, for the scales that were renamed when + * the design system moved to Tailwind's numeric-px namespaces. Colors need no + * map: upstream `--ds-` is our bare `--`. */ +const SPACING_MAP = { + 'space-none': 'spacing-0', + 'space-2xs': 'spacing-4', + 'space-xs': 'spacing-8', + 'space-s': 'spacing-12', + 'space-m': 'spacing-16', + 'space-l': 'spacing-24', + 'space-xl': 'spacing-32', + 'space-3xl': 'spacing-64' +}; +const RADIUS_MAP = { + 'radius-none': 'radius-0', + 'radius-xs': 'radius-2', + 'radius-s': 'radius-4', + 'radius-m': 'radius-8', + 'radius-l': 'radius-12', + 'radius-xl': 'radius-16', + 'radius-full': 'radius-full' +}; +const TYPE_MAP = Object.fromEntries( + ['xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', '5xl'].map((k) => [`font-size-${k}`, `text-${k}`]) +); +const MISC_MAP = { + 'elevation-l': 'shadow-elevation-l', + 'motion-fast': 'motion-fast', + 'motion-base': 'motion-base', + 'font-family-default': 'font-standard', + 'font-family-display': 'font-display', + 'font-family-mono': 'font-mono' +}; + +/** Declared in our mirror but deliberately absent upstream — never reported as + * drift. Each needs a reason, and the mirror must say the same thing. */ +const EXPECTED_EXTRAS = { + 'focus-ring-error': 'removed upstream in 1.1.0; kept until app code stops reading it' +}; + +function norm(value) { + let v = value.trim().toLowerCase().replace(/\s+/g, ' ').replace(/,\s*/g, ', ').replace(/;$/, ''); + // The shipped sheet is minified: #ffffff becomes #fff. + v = v.replace(/#([0-9a-f])\1?([0-9a-f])\2?([0-9a-f])\3?\b/g, (m, r, g, b) => + m.length === 4 ? `#${r}${r}${g}${g}${b}${b}` : m + ); + // Upstream references live under the --ds-* namespace; ours are bare. + v = v.replace(/var\(--ds-/g, 'var(--'); + // Unitless zero and 0px are the same length. + if (v === '0') v = '0px'; + return v; +} + +/** rem values are authored upstream, px in our mirror. Compare in px. */ +function toPx(value) { + const m = /^(-?[\d.]+)rem$/.exec(value.trim()); + return m ? `${Math.round(parseFloat(m[1]) * 16)}px` : value; +} + +function parseVars(css, prefix = '') { + const out = new Map(); + const re = new RegExp(`--${prefix}([a-z0-9-]+)\\s*:\\s*([^;}]+)[;}]`, 'gi'); + for (const m of css.matchAll(re)) out.set(m[1].toLowerCase(), norm(m[2])); + return out; +} + +function fetchPublished(version) { + const dir = mkdtempSync(join(tmpdir(), 'ogcr-ds-')); + try { + const spec = `${PACKAGE}@${version}`; + const tgz = execFileSync('npm', ['pack', spec, '--pack-destination', dir, '--silent'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + .trim() + .split('\n') + .pop(); + execFileSync('tar', ['xzf', join(dir, tgz), '-C', dir]); + const read = (f) => readFileSync(join(dir, 'package/dist', f), 'utf8'); + return { styles: read('styles.css'), theme: read('theme.css') }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function latestVersion() { + try { + return execFileSync('npm', ['view', PACKAGE, 'version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch { + return null; + } +} + +// --- run ------------------------------------------------------------------- + +const useLatest = process.argv.includes('--latest'); +const latest = latestVersion(); +const version = useLatest ? (latest ?? PINNED_VERSION) : PINNED_VERSION; + +let published; +try { + published = fetchPublished(version); +} catch (error) { + console.error(`Could not fetch ${PACKAGE}@${version}: ${error.message.split('\n')[0]}`); + console.error('Offline? This check needs the npm registry.'); + process.exit(2); +} + +const mirror = parseVars(readFileSync(MIRROR, 'utf8')); +const upstreamColors = parseVars(published.styles, 'ds-'); +const upstreamTheme = parseVars(published.theme); + +const drift = []; +const missing = []; + +// Colors: upstream is authoritative for the whole set. +for (const [name, value] of upstreamColors) { + if (!mirror.has(name)) missing.push([name, value]); + else if (mirror.get(name) !== value) drift.push([name, mirror.get(name), value, 'color']); +} + +// Renamed scales: compare our value against the upstream token it maps to. +for (const [maps, kind] of [ + [SPACING_MAP, 'spacing'], + [RADIUS_MAP, 'radius'], + [TYPE_MAP, 'type'], + [MISC_MAP, 'misc'] +]) { + for (const [ours, theirs] of Object.entries(maps)) { + if (!mirror.has(ours)) { + missing.push([ours, `(maps to --${theirs})`]); + continue; + } + const up = upstreamTheme.get(theirs); + if (up === undefined) { + drift.push([ours, mirror.get(ours), `--${theirs} no longer exists upstream`, kind]); + } else if (toPx(mirror.get(ours)) !== toPx(up)) { + drift.push([ours, mirror.get(ours), `${up} (--${theirs})`, kind]); + } + } +} + +// Anything we declare that upstream does not, beyond the documented exceptions. +const known = new Set([ + ...upstreamColors.keys(), + ...Object.keys(SPACING_MAP), + ...Object.keys(RADIUS_MAP), + ...Object.keys(TYPE_MAP), + ...Object.keys(MISC_MAP) +]); +const extras = [...mirror.keys()].filter((k) => !known.has(k) && !(k in EXPECTED_EXTRAS)); + +console.log(`${PACKAGE}@${version} vs src/ogcr-design-system-reference.css`); +console.log( + ` ${upstreamColors.size} color tokens · ${Object.keys({ ...SPACING_MAP, ...RADIUS_MAP, ...TYPE_MAP, ...MISC_MAP }).length} mapped scale tokens\n` +); + +for (const [name, ours, theirs, kind] of drift) + console.log(` DRIFTED --${name} (${kind}): ours=${ours} upstream=${theirs}`); +for (const [name, value] of missing) console.log(` MISSING --${name}: ${value}`); +for (const name of extras) console.log(` EXTRA --${name}: not in upstream, and not a documented exception`); + +if (latest && latest !== PINNED_VERSION && !useLatest) + console.log(`\n NOTE: ${PACKAGE}@${latest} is published; we are pinned to ${PINNED_VERSION}.`); + +const problems = drift.length + missing.length + extras.length; +if (problems === 0) { + console.log(' In sync.'); + process.exit(0); +} +console.log(`\n${problems} difference(s). Update the mirror, ogcr-theme.css's named token`); +console.log("block, the /design gallery, and design_system_integration.md's provenance together."); +process.exit(1); diff --git a/src/lib/chain/heartbeat.ts b/src/lib/chain/heartbeat.ts index 16157fa..6fca980 100644 --- a/src/lib/chain/heartbeat.ts +++ b/src/lib/chain/heartbeat.ts @@ -45,6 +45,40 @@ export interface Heartbeat { message: string; } +/** + * What each state means, in words a registry user can act on. + * + * Kept beside `deriveHeartbeat` so the explanation cannot drift from the rule + * that produces the state. `label` is the short form the indicator shows. + */ +export const HEARTBEAT_STATES: Record = { + live: { + label: 'Chain connected', + meaning: + 'The chain mirror (OGCR-chain-cache) ran recently and every part of its last run succeeded. On-chain data shown in the app is current.' + }, + degraded: { + label: 'Chain partial sync', + meaning: + 'The mirror is still running on schedule, but its last run failed to copy some chain records into OBP (it reported errors, or finished as "partial"). The chain itself is reachable; what you see in the app may be missing or behind for the records that failed. If this persists across runs, check the OGCR-chain-cache logs for the failing entity.' + }, + stale: { + label: 'Chain sync stalled', + meaning: + 'The mirror has not recorded a run for more than three of its sync intervals. It has probably stopped, so on-chain data in the app is frozen at the last sync.' + }, + never: { + label: 'Chain never synced', + meaning: + 'No sync record exists yet. The mirror has not been run against this OBP instance, so no chain data has reached the registry.' + }, + unknown: { + label: 'Chain sync unknown', + meaning: + 'A sync record exists but has no readable timestamp, so its age cannot be judged. Rather than guess, the app reports nothing about the connection.' + } +}; + /** * How long a record may go unrefreshed before it counts as stale. * @@ -115,7 +149,7 @@ export function deriveHeartbeat( ageSeconds, staleAfterSeconds: threshold, status, - message: `Syncing, but the last run reported ${status.error_count ?? 0} error(s). Some chain data may be out of date.` + message: `Partial sync: the last run reported ${status.error_count ?? 0} error(s). Some chain data may be out of date.` }; } diff --git a/src/lib/chain/mirrorHealth.test.ts b/src/lib/chain/mirrorHealth.test.ts index ba3d04f..5aeb713 100644 --- a/src/lib/chain/mirrorHealth.test.ts +++ b/src/lib/chain/mirrorHealth.test.ts @@ -36,12 +36,16 @@ describe('chainMirrorSnapshot', () => { expect(snap.error).toContain('No chain sync'); }); - it('is unhealthy when a fresh run had errors, rather than hiding them', () => { + it('is degraded when a fresh run had errors, rather than hiding them', () => { const snap = chainMirrorSnapshot( deriveHeartbeat(status(10, { error_count: 2, run_status: 'partial' }), NOW), NOW ); - expect(snap.status).toBe('unhealthy'); + expect(snap.status).toBe('degraded'); + expect(snap.conecutiveFailures).toBe(0); + // A partial run is reported as a warning, not an error: the mirror is running. + expect(snap.error).toBeUndefined(); + expect(snap.warning).toContain('2 error(s)'); }); it('is unknown rather than unhealthy when the mirror has never run', () => { diff --git a/src/lib/chain/mirrorHealth.ts b/src/lib/chain/mirrorHealth.ts index 5727ff6..fa394df 100644 --- a/src/lib/chain/mirrorHealth.ts +++ b/src/lib/chain/mirrorHealth.ts @@ -19,9 +19,11 @@ export const CHAIN_MIRROR_SERVICE = 'Chain mirror (OGCR-chain-cache)'; /** * Map a heartbeat onto the status page's vocabulary. * - * `degraded` maps to unhealthy rather than healthy: a run that failed to write - * some records means the mirror is not doing its job, and a status page that - * called that healthy would be hiding exactly what it exists to surface. + * `degraded` stays degraded rather than being rounded to healthy: a run that + * failed to write some records means the mirror is not fully doing its job, and + * a status page that called that healthy would be hiding exactly what it exists + * to surface. Nor is it unhealthy: the mirror is running and the chain is + * reachable, which is a different situation from a mirror that has stopped. * `never` maps to unknown instead, because a mirror that has not run yet is * unconfigured rather than broken. */ @@ -41,14 +43,15 @@ export function chainMirrorSnapshot( let status: HealthCheckSnapshot['status']; let error: string | undefined; + let warning: string | undefined; switch (heartbeat.state) { case 'live': status = 'healthy'; break; case 'degraded': - status = 'unhealthy'; - error = heartbeat.message; + status = 'degraded'; + warning = heartbeat.message; break; case 'stale': status = 'unhealthy'; @@ -72,6 +75,7 @@ export function chainMirrorSnapshot( // declared, fall back to the same default the heartbeat uses. intervalMs: heartbeat.staleAfterSeconds * 1000, ...(error ? { error } : {}), + ...(warning ? { warning } : {}), details }; } diff --git a/src/lib/components/Card.svelte b/src/lib/components/Card.svelte new file mode 100644 index 0000000..b52d551 --- /dev/null +++ b/src/lib/components/Card.svelte @@ -0,0 +1,248 @@ + + +{#snippet inner()} + {#if hasHeader} +
+ {#if leading} +
{@render leading()}
+ {/if} +
+ {#if title} + + {title} + {/if} + {#if subtitle} +

{subtitle}

+ {/if} +
+ {#if trailing} +
{@render trailing()}
+ {/if} +
+ {/if} + {#if children} +
{@render children()}
+ {/if} +{/snippet} + +{#if href} + + {@render inner()} + +{:else} +
+ {@render inner()} +
+{/if} + + diff --git a/src/lib/components/ChainHeartbeat.svelte b/src/lib/components/ChainHeartbeat.svelte index ce128f8..85f466b 100644 --- a/src/lib/components/ChainHeartbeat.svelte +++ b/src/lib/components/ChainHeartbeat.svelte @@ -1,6 +1,7 @@ @@ -79,7 +80,7 @@ {#if status?.synced_at}

Considered stalled after {heartbeat.staleAfterSeconds}s without a sync. - Last recorded at {status.synced_at}. + Last recorded at .

{/if} diff --git a/src/lib/components/Kpi.svelte b/src/lib/components/Kpi.svelte new file mode 100644 index 0000000..3bbd2d2 --- /dev/null +++ b/src/lib/components/Kpi.svelte @@ -0,0 +1,160 @@ + + +
+ {#if accentBar} + + {/if} +
+ + {#if icon} + + {/if} + {label} + +
+
{value}
+ {#if secondaryText} +

{secondaryText}

+ {/if} +
+ + diff --git a/src/lib/components/Pill.svelte b/src/lib/components/Pill.svelte new file mode 100644 index 0000000..468bbfb --- /dev/null +++ b/src/lib/components/Pill.svelte @@ -0,0 +1,85 @@ + + + + {#if dot} + + {/if} + {@render children()} + + + diff --git a/src/lib/components/ReferenceSelect.svelte b/src/lib/components/ReferenceSelect.svelte new file mode 100644 index 0000000..c0bdaac --- /dev/null +++ b/src/lib/components/ReferenceSelect.svelte @@ -0,0 +1,37 @@ + + +{#if options.length} + +{:else} + + {fallbackHint} +{/if} diff --git a/src/lib/components/SystemStatusPage.svelte b/src/lib/components/SystemStatusPage.svelte index 1b942ab..1d98619 100644 --- a/src/lib/components/SystemStatusPage.svelte +++ b/src/lib/components/SystemStatusPage.svelte @@ -120,6 +120,9 @@ lines.push(`${key}: ${value}`); } } + if (service.warning) { + lines.push(`Warning: ${service.warning}`); + } if (service.error) { lines.push(`Error: ${service.error}`); } @@ -156,7 +159,7 @@ case 'partial': return 'bg-orange-500'; case 'degraded': - return 'bg-yellow-500'; + return 'bg-amber-500'; case 'unknown': default: return 'bg-gray-500'; @@ -173,7 +176,7 @@ case 'partial': return 'text-orange-600 dark:text-orange-400'; case 'degraded': - return 'text-yellow-600 dark:text-yellow-400'; + return 'text-amber-600 dark:text-amber-400'; case 'unknown': default: return 'text-gray-600 dark:text-gray-400'; @@ -326,6 +329,11 @@
{data.summary.healthy} of {data.summary.total} services healthy
+ {#if data.summary.degraded > 0} +
+ {data.summary.degraded} service{data.summary.degraded === 1 ? '' : 's'} degraded — working, but not fully +
+ {/if} {#if data.summary.stale > 0}
{data.summary.stale} check{data.summary.stale === 1 ? '' : 's'} stale — counted as unknown @@ -428,6 +436,13 @@ {/each} {/if} + {#if service.warning} +
+

+ Warning: {service.warning} +

+
+ {/if} {#if service.error}

@@ -461,7 +476,8 @@

{:else if data.chainMirrorChecked}

- Chain detail and recent on-chain activity: the chain page. + Chain detail, what each chain status means, and recent on-chain activity: + the chain page.

{/if} diff --git a/src/lib/components/Table.svelte b/src/lib/components/Table.svelte new file mode 100644 index 0000000..75127dd --- /dev/null +++ b/src/lib/components/Table.svelte @@ -0,0 +1,351 @@ + + + + +
+ + +
+ + {#if caption} + + {/if} + + + {#each columns as column (column.key)} + + {/each} + + + + {#if sortedRows.length === 0} + + + + {:else} + {#each sortedRows as row, index (index)} + + {#each columns as column (column.key)} + + {/each} + + {/each} + {/if} + +
{caption}
+ {#if column.sortable} + + {:else} + {column.header} + {/if} +
{emptyMessage}
+ {#if cell} + {@render cell(row, column)} + {:else} + {row[column.key] ?? ''} + {/if} +
+
+
+ + diff --git a/src/lib/components/Timestamp.svelte b/src/lib/components/Timestamp.svelte new file mode 100644 index 0000000..3d7e57a --- /dev/null +++ b/src/lib/components/Timestamp.svelte @@ -0,0 +1,22 @@ + + +{#if display} + +{:else} + — +{/if} diff --git a/src/lib/constants/entities.ts b/src/lib/constants/entities.ts index 7ee226c..cac3d54 100644 --- a/src/lib/constants/entities.ts +++ b/src/lib/constants/entities.ts @@ -10,6 +10,12 @@ export const ENTITY_OPERATOR = `${ENTITY_PREFIX}operator`; // email-matching used by getOperatorsForUser once records exist. export const ENTITY_USER_OPERATOR_RELATIONSHIP = `${ENTITY_PREFIX}user_operator_relationship`; export const ENTITY_PARCEL = `${ENTITY_PREFIX}parcel`; +// Reference list of countries: `country_id` (ISO code) + `country_name`. Other +// entities (operator, activity) store the `country_id`. +export const ENTITY_COUNTRY = `${ENTITY_PREFIX}country`; +// Reference list of farming technologies, practices and processes. An activity +// stores the `technologies_practices_processes_id` of one of these records. +export const ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES = `${ENTITY_PREFIX}technologies_practices_processes`; export const ENTITY_PARCEL_OWNERSHIP_VERIFICATION = `${ENTITY_PREFIX}parcel_owner_verification`; export const ENTITY_ACTIVITY_PARCEL_VERIFICATION = `${ENTITY_PREFIX}activity_parcel_verification`; export const ENTITY_ACTIVITY_VERIFICATION = `${ENTITY_PREFIX}activity_verification`; @@ -35,6 +41,8 @@ export const ENTITY_CONSTANTS = { ENTITY_OPERATOR, ENTITY_USER_OPERATOR_RELATIONSHIP, ENTITY_PARCEL, + ENTITY_COUNTRY, + ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES, ENTITY_PARCEL_OWNERSHIP_VERIFICATION, ENTITY_ACTIVITY_PARCEL_VERIFICATION, ENTITY_ACTIVITY_VERIFICATION, diff --git a/src/lib/health-check/state/HealthCheckState.ts b/src/lib/health-check/state/HealthCheckState.ts index 2e305e6..1bbcd1e 100644 --- a/src/lib/health-check/state/HealthCheckState.ts +++ b/src/lib/health-check/state/HealthCheckState.ts @@ -1,8 +1,11 @@ export interface HealthCheckSnapshot { service: string; - status: 'healthy' | 'unhealthy' | 'unknown'; + /** 'degraded': working, but not fully, e.g. a sync run where some records failed. */ + status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'; responseTimeMs?: number; error?: string; + /** Something worth knowing that is not a failure, e.g. a partly failed sync run. */ + warning?: string; lastChecked: string; // ISO timestamp conecutiveFailures: number; intervalMs?: number; // How often this check runs — lets consumers detect stale results diff --git a/src/lib/health-check/summarize.test.ts b/src/lib/health-check/summarize.test.ts new file mode 100644 index 0000000..91003f9 --- /dev/null +++ b/src/lib/health-check/summarize.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeHealth } from './summarize'; +import type { HealthCheckSnapshot } from './state/HealthCheckState'; + +const NOW = new Date('2026-09-25T12:00:00Z'); + +function snap(service: string, status: HealthCheckSnapshot['status']): HealthCheckSnapshot { + return { service, status, lastChecked: NOW.toISOString(), conecutiveFailures: 0 }; +} + +describe('summarizeHealth with degraded services', () => { + it('is degraded, not healthy, when a service is degraded and the rest are healthy', () => { + const s = summarizeHealth({ a: snap('a', 'healthy'), b: snap('b', 'degraded') }, NOW); + expect(s.overallStatus).toBe('degraded'); + expect(s.summary.degraded).toBe(1); + expect(s.summary.healthy).toBe(1); + }); + + it('is unhealthy when another service is down, whatever else is degraded', () => { + const s = summarizeHealth({ a: snap('a', 'unhealthy'), b: snap('b', 'degraded') }, NOW); + expect(s.overallStatus).toBe('unhealthy'); + }); +}); diff --git a/src/lib/health-check/summarize.ts b/src/lib/health-check/summarize.ts index 4c895bf..a6cfd4e 100644 --- a/src/lib/health-check/summarize.ts +++ b/src/lib/health-check/summarize.ts @@ -1,6 +1,6 @@ import type { HealthCheckSnapshot } from './state/HealthCheckState'; -export type OverallStatus = 'healthy' | 'partial' | 'unhealthy' | 'unknown'; +export type OverallStatus = 'healthy' | 'partial' | 'degraded' | 'unhealthy' | 'unknown'; // Provider checks registered as 'OAuth2: ' form one login group: // users need only one working provider, so a broken provider alongside a @@ -20,6 +20,7 @@ export interface HealthSummary { summary: { total: number; healthy: number; + degraded: number; unhealthy: number; unknown: number; stale: number; @@ -38,6 +39,8 @@ const DEFAULT_STALE_AFTER_MS = 3 * 60_000; * - OAuth2 provider checks are one group: as long as one provider is healthy, * failed providers yield 'partial' instead of 'unhealthy'. With no healthy * provider left (or a non-OAuth service down), the status is 'unhealthy'. + * - A service reporting 'degraded' is working but not fully, so the system is + * 'degraded': never 'healthy', but not as bad as a service that is down. */ export function summarizeHealth( snapshots: Record, @@ -54,6 +57,7 @@ export function summarizeHealth( const list = Object.values(services); const effectiveStatus = (s: ServiceHealthView) => (s.stale ? 'unknown' : s.status); const healthy = list.filter((s) => effectiveStatus(s) === 'healthy').length; + const degraded = list.filter((s) => effectiveStatus(s) === 'degraded').length; const unhealthy = list.filter((s) => effectiveStatus(s) === 'unhealthy').length; const unknown = list.filter((s) => effectiveStatus(s) === 'unknown').length; @@ -71,6 +75,8 @@ export function summarizeHealth( overallStatus = 'unhealthy'; } else if (oauthUnhealthy > 0) { overallStatus = 'partial'; + } else if (degraded > 0) { + overallStatus = 'degraded'; } else if (unknown > 0) { overallStatus = 'unknown'; } else { @@ -85,6 +91,7 @@ export function summarizeHealth( summary: { total: list.length, healthy, + degraded, unhealthy, unknown, stale: list.filter((s) => s.stale).length diff --git a/src/lib/reference/countries.test.ts b/src/lib/reference/countries.test.ts new file mode 100644 index 0000000..12d0696 --- /dev/null +++ b/src/lib/reference/countries.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const get = vi.fn(); + +vi.mock('$lib/obp/requests', () => ({ + obp_requests: { get } +})); + +const { getCountries } = await import('./countries'); + +describe('getCountries', () => { + beforeEach(() => vi.clearAllMocks()); + + it('reads the country entity list and sorts by name', async () => { + get.mockResolvedValueOnce({ + country_list: [ + { country_id: 'DE', country_name: 'Germany' }, + { country_id: 'AT', country_name: 'Austria' } + ] + }); + + expect(await getCountries('tok')).toEqual([ + { country_id: 'AT', country_name: 'Austria' }, + { country_id: 'DE', country_name: 'Germany' } + ]); + expect(get).toHaveBeenCalledWith('/obp/dynamic-entity/country', 'tok'); + }); + + it('drops records without a country_id and falls back to the id for a missing name', async () => { + get.mockResolvedValueOnce({ + country_list: [{ country_name: 'Nowhere' }, { country_id: 'FR' }] + }); + + expect(await getCountries('tok')).toEqual([{ country_id: 'FR', country_name: 'FR' }]); + }); + + it('returns an empty list when the entity has no records', async () => { + get.mockResolvedValueOnce({}); + expect(await getCountries('tok')).toEqual([]); + }); +}); diff --git a/src/lib/reference/countries.ts b/src/lib/reference/countries.ts new file mode 100644 index 0000000..f93a67c --- /dev/null +++ b/src/lib/reference/countries.ts @@ -0,0 +1,26 @@ +import { obp_requests } from '$lib/obp/requests'; +import { ENTITY_COUNTRY } from '$lib/constants/entities'; +import type { CountryRecord } from './options'; + +export type { CountryRecord }; + +/** + * The countries the registry knows about, from the `country` dynamic entity. + * + * `country_id` is the value stored on other entities (operator, activity), so a + * form offers `country_name` and submits `country_id`. Sorted by name, since + * that is the order a picker reads in. + */ +export async function getCountries(accessToken: string): Promise { + const response = await obp_requests.get(`/obp/dynamic-entity/${ENTITY_COUNTRY}`, accessToken); + const records = (response?.[`${ENTITY_COUNTRY}_list`] || []) as Array>; + + return records + .map((record) => ({ + country_id: typeof record.country_id === 'string' ? record.country_id : '', + country_name: typeof record.country_name === 'string' ? record.country_name : '' + })) + .filter((country) => country.country_id !== '') + .map((country) => ({ ...country, country_name: country.country_name || country.country_id })) + .sort((a, b) => a.country_name.localeCompare(b.country_name)); +} diff --git a/src/lib/reference/options.test.ts b/src/lib/reference/options.test.ts new file mode 100644 index 0000000..11e478a --- /dev/null +++ b/src/lib/reference/options.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { countryOptions, practiceOptions } from './options'; + +describe('reference options', () => { + it('countryOptions labels the name with its code, and tolerates undefined', () => { + expect(countryOptions([{ country_id: 'DE', country_name: 'Germany' }])).toEqual([ + { id: 'DE', label: 'Germany (DE)' } + ]); + expect(countryOptions(undefined)).toEqual([]); + }); + + it('practiceOptions maps to id + label, and tolerates undefined', () => { + expect( + practiceOptions([ + { technologies_practices_processes_id: 'tpp1', practice_name: 'Cover cropping' } + ]) + ).toEqual([{ id: 'tpp1', label: 'Cover cropping' }]); + expect(practiceOptions(undefined)).toEqual([]); + }); +}); diff --git a/src/lib/reference/options.ts b/src/lib/reference/options.ts new file mode 100644 index 0000000..65f3b40 --- /dev/null +++ b/src/lib/reference/options.ts @@ -0,0 +1,38 @@ +// Client-safe half of the reference lists: the record shapes and the mappers a +// form uses to render a picker. Deliberately imports nothing server-only — the +// fetchers next door reach $lib/constants/entities, which reads private env and +// so must never be pulled into a browser bundle. + +/** One choice in a reference picker: the id that gets stored, and what a human reads. */ +export interface ReferenceOption { + id: string; + label: string; +} + +export interface CountryRecord { + /** ISO code, e.g. "DE". This is what operator.country_id / activity.country_id hold. */ + country_id: string; + country_name: string; +} + +export interface PracticeRecord { + /** What activity.technologies_practices_processes_id holds. */ + technologies_practices_processes_id: string; + practice_name: string; +} + +/** Countries as picker options: "Germany (DE)" → "DE". */ +export function countryOptions(countries: CountryRecord[] | undefined): ReferenceOption[] { + return (countries ?? []).map((country) => ({ + id: country.country_id, + label: `${country.country_name} (${country.country_id})` + })); +} + +/** Practices as picker options: the practice name → its id. */ +export function practiceOptions(practices: PracticeRecord[] | undefined): ReferenceOption[] { + return (practices ?? []).map((practice) => ({ + id: practice.technologies_practices_processes_id, + label: practice.practice_name + })); +} diff --git a/src/lib/reference/practices.test.ts b/src/lib/reference/practices.test.ts new file mode 100644 index 0000000..869d00a --- /dev/null +++ b/src/lib/reference/practices.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const get = vi.fn(); + +vi.mock('$lib/obp/requests', () => ({ + obp_requests: { get } +})); + +const { getPractices } = await import('./practices'); + +describe('getPractices', () => { + beforeEach(() => vi.clearAllMocks()); + + it('reads the practice entity list and sorts by name', async () => { + get.mockResolvedValueOnce({ + technologies_practices_processes_list: [ + { technologies_practices_processes_id: 'tpp2', practice_name: 'Reduced tillage' }, + { technologies_practices_processes_id: 'tpp1', practice_name: 'Cover cropping' } + ] + }); + + expect(await getPractices('tok')).toEqual([ + { technologies_practices_processes_id: 'tpp1', practice_name: 'Cover cropping' }, + { technologies_practices_processes_id: 'tpp2', practice_name: 'Reduced tillage' } + ]); + expect(get).toHaveBeenCalledWith('/obp/dynamic-entity/technologies_practices_processes', 'tok'); + }); + + it('drops records without an id and falls back to the id for a missing name', async () => { + get.mockResolvedValueOnce({ + technologies_practices_processes_list: [ + { practice_name: 'Orphan' }, + { technologies_practices_processes_id: 'tpp3' } + ] + }); + + expect(await getPractices('tok')).toEqual([ + { technologies_practices_processes_id: 'tpp3', practice_name: 'tpp3' } + ]); + }); +}); diff --git a/src/lib/reference/practices.ts b/src/lib/reference/practices.ts new file mode 100644 index 0000000..4de9622 --- /dev/null +++ b/src/lib/reference/practices.ts @@ -0,0 +1,32 @@ +import { obp_requests } from '$lib/obp/requests'; +import { ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES } from '$lib/constants/entities'; +import type { PracticeRecord } from './options'; + +export type { PracticeRecord }; + +/** + * The technologies, practices and processes the registry knows about. + * + * An activity used to carry the practice as free text; it now stores the id of + * a record in this entity, so a form offers `practice_name` and submits the id. + */ +export async function getPractices(accessToken: string): Promise { + const entity = ENTITY_TECHNOLOGIES_PRACTICES_PROCESSES; + const response = await obp_requests.get(`/obp/dynamic-entity/${entity}`, accessToken); + const records = (response?.[`${entity}_list`] || []) as Array>; + + return records + .map((record) => ({ + technologies_practices_processes_id: + typeof record.technologies_practices_processes_id === 'string' + ? record.technologies_practices_processes_id + : '', + practice_name: typeof record.practice_name === 'string' ? record.practice_name : '' + })) + .filter((practice) => practice.technologies_practices_processes_id !== '') + .map((practice) => ({ + ...practice, + practice_name: practice.practice_name || practice.technologies_practices_processes_id + })) + .sort((a, b) => a.practice_name.localeCompare(b.practice_name)); +} diff --git a/src/lib/registry/activities.ts b/src/lib/registry/activities.ts new file mode 100644 index 0000000..6a843c3 --- /dev/null +++ b/src/lib/registry/activities.ts @@ -0,0 +1,102 @@ +import { env } from '$env/dynamic/public'; + +/** + * The public registry read model. + * + * Unlike the rest of the app, the registry does NOT read dynamic entities directly. + * It calls one OBP Dynamic Resource Doc that joins activity, operator, country, + * certificate_of_compliance and activity_verification server-side and projects only + * the columns below. That matters for two reasons: + * + * - It is public. The endpoint is created with `roles: ""`, so no access token is + * involved and a signed-out visitor sees the same registry a buyer would. + * - Because it is public, the projection IS the access control. Opening the + * underlying entities for public read would expose every field on them; this + * exposes the registry columns and nothing else. + * + * Source of the endpoint: OGCR-DynamicEntities/registry_activities_endpoint.scala, + * deployed with dynamic_resource_docs.py. + */ + +/** Served under an extra `dynamic-resource-doc` segment — see the note in + * OGCR-DynamicEntities/dynamic_resource_docs.py; the Dynamic Endpoint glossary + * documents a shorter path that 404s. */ +export const REGISTRY_ACTIVITIES_PATH = + '/obp/dynamic-endpoint/dynamic-resource-doc/registry/activities'; + +/** One row of the registry. Every field can be null: the endpoint left-joins, and an + * activity with no certificate or no resolvable operator is normal, not an error. */ +export interface RegistryActivity { + activity_id: string | null; + name: string | null; + summary: string | null; + activity_type: string | null; + country_id: string | null; + country_name: string | null; + city: string | null; + start_date: string | null; + end_date: string | null; + monitoring_period_start_date: string | null; + monitoring_period_end_date: string | null; + operator_id: string | null; + operator_legal_name: string | null; + verification_status: string | null; + certificate_of_compliance_id: string | null; + certification_status: string | null; + certificate_issue_date: string | null; + certificate_expiry_date: string | null; +} + +export interface RegistryActivitiesResult { + activities: RegistryActivity[]; + count: number; + error: string | null; +} + +/** + * Fetch the registry activities. Never throws: the registry pages are mostly public + * copy, and a registry outage should degrade the data rather than replace the page + * with an error. Callers render `error` themselves. + * + * Pass the `fetch` from a SvelteKit `load` so the request participates in SSR. + */ +export async function getRegistryActivities( + fetchFn: typeof fetch = fetch +): Promise { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + const url = `${base}${REGISTRY_ACTIVITIES_PATH}`; + + try { + const response = await fetchFn(url); + if (!response.ok) { + return { + activities: [], + count: 0, + error: `Registry returned HTTP ${response.status} from ${REGISTRY_ACTIVITIES_PATH}` + }; + } + const body = (await response.json()) as { activities?: RegistryActivity[]; count?: number }; + const activities = body.activities ?? []; + return { activities, count: body.count ?? activities.length, error: null }; + } catch (error) { + return { + activities: [], + count: 0, + error: error instanceof Error ? error.message : 'Could not reach the registry' + }; + } +} + +/** Distinct non-empty values of a field, sorted — used to populate the filter dropdowns + * from the data actually present rather than a hardcoded list. */ +export function distinctValues( + activities: RegistryActivity[], + field: keyof RegistryActivity +): string[] { + const seen = new Set(); + for (const activity of activities) { + const value = activity[field]; + if (typeof value === 'string' && value.trim() !== '') seen.add(value); + } + return [...seen].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/lib/server/roleChecker.ts b/src/lib/server/roleChecker.ts index 2b2d8a9..3c7f518 100644 --- a/src/lib/server/roleChecker.ts +++ b/src/lib/server/roleChecker.ts @@ -5,19 +5,23 @@ import { ENTITY_ACTIVITY, ENTITY_OPERATOR, ENTITY_USER_OPERATOR_RELATIONSHIP } from '$lib/constants/entities'; import type { PageRoleConfig } from '$lib/utils/roleCheck'; -// Role names for system-level dynamic entities are `CanGetDynamicEntity_System`, -// where entityName is exactly the string OBP-API registered the entity under. +// Role names for the records of a dynamic entity are `CanGetDynamicEntityRecord_`, where +// entityName is exactly the string OBP-API registered the entity under. The name no longer says which +// space the entity lives in -- until 2026-09-24 a system level entity had a separate +// `CanGetDynamicEntity_System` Role -- because the bank id of the grant says it instead: +// the literal `SYS` for the system space, or a real bank id. These checks do not name a bank id, so +// they match a grant in any space; add `bankId` to a requirement if a page should be bank specific. export const SITE_MAP: Record = { '/activities': { - required: [{ role: `CanGetDynamicEntity_System${ENTITY_ACTIVITY}` }] + required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_ACTIVITY}` }] }, '/operators': { - required: [{ role: `CanGetDynamicEntity_System${ENTITY_OPERATOR}` }] + required: [{ role: `CanGetDynamicEntityRecord_${ENTITY_OPERATOR}` }] }, '/my/operators': { required: [ - { role: `CanGetDynamicEntity_System${ENTITY_OPERATOR}` }, - { role: `CanGetDynamicEntity_System${ENTITY_USER_OPERATOR_RELATIONSHIP}` } + { role: `CanGetDynamicEntityRecord_${ENTITY_OPERATOR}` }, + { role: `CanGetDynamicEntityRecord_${ENTITY_USER_OPERATOR_RELATIONSHIP}` } ], requirementType: 'AND' } diff --git a/src/lib/server/systemStatus.ts b/src/lib/server/systemStatus.ts new file mode 100644 index 0000000..a1f328d --- /dev/null +++ b/src/lib/server/systemStatus.ts @@ -0,0 +1,62 @@ +/** + * Everything the status page shows, built in one place. + * + * The page loader and `/backend/status` (which the page's auto-refresh polls) + * both call this. When they were separate, the refresh returned only the + * background checks, so the chain mirror and the backlog vanished from the page + * thirty seconds after it loaded. + */ + +import { healthCheckRegistry, summarizeHealth, type HealthSummary } from '$lib/health-check'; +import { getChainSyncStatus, getTokenizationBacklog } from '$lib/chain/onChain'; +import type { Backlog } from '$lib/chain/backlog'; +import { deriveHeartbeat } from '$lib/chain/heartbeat'; +import { chainMirrorSnapshot, chainMirrorUncheckable } from '$lib/chain/mirrorHealth'; + +export type SystemStatusData = HealthSummary & { + chainMirrorChecked: boolean; + backlog: Backlog | null; + backlogError: string | null; +}; + +export async function buildSystemStatus(accessToken: string | undefined): Promise { + const snapshots = { ...healthCheckRegistry.getSnapshots() }; + + // The chain mirror cannot be polled in the background like the other checks: + // reading its status record needs OBP credentials, which only a request + // carries. So it is evaluated here, per request, and only when we have a + // token to do it with. Adding an always-unknown entry for logged-out + // visitors would drag the whole page's overall status to unknown while + // telling them nothing. + let backlog: Backlog | null = null; + let backlogError: string | null = null; + + if (accessToken) { + try { + const status = await getChainSyncStatus(accessToken); + const snapshot = chainMirrorSnapshot(deriveHeartbeat(status)); + snapshots[snapshot.service] = snapshot; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const snapshot = chainMirrorUncheckable(`Could not read the chain mirror: ${message}`); + snapshots[snapshot.service] = snapshot; + } + + // Deliberately NOT folded into summarizeHealth. A backlog is a measurement, + // not a health state: a snapshot cannot tell a queue being worked through + // from one that is stuck, so forcing it into healthy/unhealthy would either + // cry wolf on normal lag or hide a genuinely stalled tokenizer. + try { + backlog = await getTokenizationBacklog(accessToken); + } catch (error) { + backlogError = error instanceof Error ? error.message : 'Unknown error'; + } + } + + return { + ...summarizeHealth(snapshots), + chainMirrorChecked: !!accessToken, + backlog, + backlogError + }; +} diff --git a/src/lib/utils/datetime.test.ts b/src/lib/utils/datetime.test.ts new file mode 100644 index 0000000..225b9b1 --- /dev/null +++ b/src/lib/utils/datetime.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { formatUtc, formatLocal } from './datetime'; + +describe('formatUtc', () => { + it('renders the instant in UTC and says so', () => { + const out = formatUtc('2026-09-09T06:52:38Z')!; + // Deliberately not asserting the month abbreviation: ICU renders + // September as "Sep" or "Sept" depending on its version, and pinning that + // makes the test fail on a Node upgrade for no useful reason. + expect(out).toMatch(/^9 \w+ 2026, 06:52:38 UTC$/); + }); + + it('is independent of the offset in the input, since it is the same instant', () => { + expect(formatUtc('2026-09-09T08:52:38+02:00')).toBe(formatUtc('2026-09-09T06:52:38Z')); + }); + + it('returns null rather than "Invalid Date" for unusable input', () => { + expect(formatUtc(null)).toBeNull(); + expect(formatUtc(undefined)).toBeNull(); + expect(formatUtc('')).toBeNull(); + expect(formatUtc('not-a-date')).toBeNull(); + }); +}); + +describe('formatLocal', () => { + it('formats a valid instant', () => { + expect(formatLocal('2026-09-09T06:52:38Z')).toContain('2026'); + }); + + it('returns null for unusable input', () => { + expect(formatLocal('nope')).toBeNull(); + }); +}); diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts new file mode 100644 index 0000000..f68cea2 --- /dev/null +++ b/src/lib/utils/datetime.ts @@ -0,0 +1,42 @@ +/** + * Timestamp formatting for values that are rendered on the server and then + * hydrated in the browser. + * + * The two run in different time zones and locales, so formatting a date the + * obvious way produces a hydration mismatch: the server renders its own local + * time, the browser replaces it with a different string. Rendering UTC with a + * fixed locale is deterministic, so it is what both sides emit first; the + * browser then upgrades it to the reader's own time once mounted. + */ + +/** Locale pinned so server and client agree on the pre-hydration string. */ +const STABLE_LOCALE = 'en-GB'; + +const OPTIONS: Intl.DateTimeFormatOptions = { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' +}; + +function parse(iso: string | null | undefined): Date | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isNaN(ms) ? null : new Date(ms); +} + +/** Deterministic UTC rendering, safe to emit from the server. */ +export function formatUtc(iso: string | null | undefined): string | null { + const date = parse(iso); + if (!date) return null; + return `${date.toLocaleString(STABLE_LOCALE, { ...OPTIONS, timeZone: 'UTC' })} UTC`; +} + +/** The reader's own time zone and locale. Browser only. */ +export function formatLocal(iso: string | null | undefined): string | null { + const date = parse(iso); + if (!date) return null; + return date.toLocaleString(undefined, OPTIONS); +} diff --git a/src/lib/utils/roleCheck.test.ts b/src/lib/utils/roleCheck.test.ts index 854eea8..51775af 100644 --- a/src/lib/utils/roleCheck.test.ts +++ b/src/lib/utils/roleCheck.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { checkRoles, type UserEntitlement } from './roleCheck'; const entitlements: UserEntitlement[] = [ - { entitlement_id: 'e1', role_name: 'CanGetDynamicEntity_Systemactivity', bank_id: '' }, + { entitlement_id: 'e1', role_name: 'CanGetDynamicEntityRecord_activity', bank_id: 'SYS' }, { entitlement_id: 'e2', role_name: 'CanCreateAccount', bank_id: 'gh.29.uk' } ]; @@ -15,17 +15,17 @@ describe('checkRoles', () => { it('OR (default): any one required role is enough', () => { const result = checkRoles( - [{ role: 'CanGetDynamicEntity_Systemactivity' }, { role: 'CanDoSomethingElse' }], + [{ role: 'CanGetDynamicEntityRecord_activity' }, { role: 'CanDoSomethingElse' }], entitlements ); expect(result.hasAllRoles).toBe(true); - expect(result.hasRoles).toEqual([{ role: 'CanGetDynamicEntity_Systemactivity' }]); + expect(result.hasRoles).toEqual([{ role: 'CanGetDynamicEntityRecord_activity' }]); expect(result.missingRoles).toEqual([{ role: 'CanDoSomethingElse' }]); }); it('AND: every required role must be present', () => { const result = checkRoles( - [{ role: 'CanGetDynamicEntity_Systemactivity' }, { role: 'CanDoSomethingElse' }], + [{ role: 'CanGetDynamicEntityRecord_activity' }, { role: 'CanDoSomethingElse' }], entitlements, 'AND' ); diff --git a/src/ogcr-design-system-reference.css b/src/ogcr-design-system-reference.css index 22613a6..fd8d381 100644 --- a/src/ogcr-design-system-reference.css +++ b/src/ogcr-design-system-reference.css @@ -1,19 +1,48 @@ /* ============================================================ - * OGCR Design System — REFERENCE COPY (do not import) + * OGCR Design System — REFERENCE MIRROR (do not import) * - * Verbatim copy of OGCR-design/ogcr-design-system/src/index.css - * captured 2026-05-04. Kept here so the canonical token list lives - * inside this repo for offline reference. The active theme is in - * ./ogcr-theme.css, which mirrors these tokens onto Skeleton's - * --color-* scale. + * Hand-mirrored token values from the OGCR Design System. Kept here + * so the canonical token list lives inside this repo for offline + * reference. The active theme is in ./ogcr-theme.css, which mirrors + * these tokens onto Skeleton's --color-* scale. * - * If the upstream design system changes, re-copy this file and - * reconcile differences into ogcr-theme.css. + * The design system is React; this app is Svelte, so its components + * cannot be imported and we mirror the values by hand. Why that is, + * what it costs, and how to re-reconcile when upstream moves: * - * Original header: - * OGCR Design System — tokens - * Source: Figma file 2P6XrQJhT8I39IR5LGK7RT (OGCR – Design System) - * Mirrors the Figma variable names; see docs/design-system.md. + * ../design_system_integration.md + * + * ---- Upstream provenance ----------------------------------- + * repo git@github.com:Maji-Studio/ogcr.git (branch: main) + * read at 182d425e76ce204826c9b2440930b5a8f7d02094 (2026-09-07) + * package @majistudio/ogcr-design-system 1.1.0 + * source packages/design-system/src/styles/palette.css (colors) + * packages/design-system/src/styles/theme.css (the rest) + * spec packages/design-system/docs/design-system.md + * + * Tokens last changed upstream by: + * e9471331f60bec1d76c91198771be4f392602cb7 (2026-08-13) + * "feat(ds): align tokens to figma and ship the full stylesheet contract" + * + * e947133 is the pin that matters — 182d425 is only the HEAD we + * read. If `git log e947133..main -- packages/design-system/src/styles/` + * is empty, this file is current however far HEAD has moved. + * + * Upstream's own source of truth is Figma file + * 2P6XrQJhT8I39IR5LGK7RT (OGCR – Design System), which it last + * reconciled on 2026-08-13. + * + * The predecessor repo Maji-Studio/ogcr-design-system is ARCHIVED + * and frozen at 1.0.0. Do not reconcile against it. + * + * Reconciled: 2026-09-22. The previous copy claimed to mirror + * src/index.css @ 2026-05-04; that file no longer exists upstream + * (split into palette.css + theme.css) and 24 of 62 color tokens had + * drifted or gone missing. See the doc for the full list. + * + * Naming note: upstream ships these as `--ds-` (palette) and + * `--color-` (Tailwind @theme). We keep the spec's own bare + * names, which is what ogcr-theme.css and app code reference. * ============================================================ */ :root { @@ -28,23 +57,32 @@ --orange-400: #fb923c; --orange-500: #f97316; --amber-300: #fcd34d; + --yellow-300: #fde047; --white: #ffffff; /* --- Text --------------------------------------------------- */ --text-primary: #0f3655; --text-secondary: #6a8196; --text-neutral: #334155; - --text-positive: #416c51; + --text-positive: #4f8263; --text-negative: #b91c1c; --text-warning: #c2410c; + --text-progress: #265277; /* --- Icon --------------------------------------------------- */ --icon-primary: #0f3655; --icon-secondary: #6a8196; --icon-neutral: #334155; - --icon-positive: #416c51; + --icon-positive: #4f8263; --icon-negative: #b91c1c; --icon-warning: #c2410c; + --icon-progress: #265277; + /* `-light` tier — Figma icon/{positive,negative,warning,progress}-light. + Upstream components used to fake these with the raw primitives. */ + --icon-positive-light: #6db087; /* brand-green-500 */ + --icon-negative-light: #ef4444; /* red-500 */ + --icon-warning-light: #f97316; /* orange-500 */ + --icon-progress-light: #326d9e; /* brand-blue-500 */ /* --- Surface ------------------------------------------------- */ --surface-page: #f8f3ef; @@ -55,27 +93,56 @@ --surface-positive: #e2efe6; --surface-warning: #ffedd5; --surface-negative: #fee2e2; + --surface-progress: #e2edf6; /* --- Border -------------------------------------------------- */ --border-light: #e7e5e4; --border-medium: #d6d3d1; --border-strong: #a8a29e; + /* Code-only upstream: no Figma provenance. Do not use in new code. */ --border-default: #01012e14; --border-high-contrast: #443321; --border-positive-light: #c5dfce; --border-warning-light: #fed7aa; --border-negative-light: #fecaca; --border-negative-strong: #dc2626; + /* `-strong` tier. Figma's border/neutral-light is deliberately not + imported upstream: it duplicates border/light (a Figma defect). */ + --border-neutral-strong: #57534e; /* stone-600 */ + --border-positive-strong: #5e9975; /* brand-green-600 */ + --border-warning-strong: #ea580c; /* orange-600 */ /* --- Interaction -------------------------------------------- */ --interaction-primary-default: #4f8263; - --interaction-primary-hover: #416c51; - --interaction-primary-active: #416c51; + --interaction-primary-hover: #335641; + --interaction-primary-active: #335641; --interaction-primary-focus: #e2efe6; - --interaction-secondary-focus: #e2d0bf; + /* Secondary tier — `default` is white (a secondary button is an + outlined white surface); hover/active are the brand-blue fill. */ + --interaction-secondary-default: #ffffff; + --interaction-secondary-hover: #1c3d59; /* brand-blue-800 */ + --interaction-secondary-active: #1c3d59; /* brand-blue-800 */ + --interaction-secondary-focus: #c3daed; + /* Code-only upstream: Figma has no tertiary interaction tier. */ + --interaction-tertiary-active: #e2efe6; + + /* DEPRECATED — removed upstream in 1.1.0. Per the spec: "It existed, + referenced nothing, and was removed; use --focus-error (the full + shadow) instead." Kept only because app code still reads it. */ --focus-ring-error: #fecaca; - /* --- Spacing ------------------------------------------------- */ + /* --- Chrome: scrollbars ------------------------------------- */ + --scrollbar-track: transparent; + --scrollbar-thumb: var(--border-medium); + + /* --- Stacking (runtime-overridable upstream) ---------------- */ + --z-overlay: 50; + --z-toast: 100; + + /* --- Spacing ------------------------------------------------- * + * The shipped stylesheet expresses these as a numeric-px scale + * (--space-m 16px is --spacing-16 there). These are the spec's + * t-shirt names. `2xl` is undefined upstream by design. */ --space-none: 0; --space-2xs: 4px; --space-xs: 8px; @@ -85,16 +152,20 @@ --space-xl: 32px; --space-3xl: 64px; - /* --- Radius -------------------------------------------------- */ + /* --- Radius -------------------------------------------------- * + * Shipped as a numeric-px scale (--radius-l 12px is --radius-12). */ --radius-none: 0; --radius-xs: 2px; --radius-s: 4px; --radius-m: 8px; --radius-l: 12px; --radius-xl: 16px; - --radius-full: 999px; + --radius-full: 9999px; - /* --- Elevation ---------------------------------------------- */ + /* --- Elevation ---------------------------------------------- * + * Figma defines exactly ONE elevation effect. There is no + * s/m/xs step and none should be invented — `shadow-elevation-s` + * does not exist and silently produces nothing. */ --elevation-l: 0 0 2px 0 rgba(68, 51, 33, 0.08), 0 8px 16px 0 rgba(68, 51, 33, 0.16); @@ -104,8 +175,11 @@ --font-family-display: 'Helvetica Now Display', system-ui, -apple-system, sans-serif; --font-family-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; - --font-size-xs: 14px; - --font-size-s: 16px; + /* Ladder: 10/14/18/20/24/32/40/48/64. `m` is 18px (Figma font/size/m); + it was the one wrong step upstream and moved 16 -> 18, so body copy + and h4 grew 2px. 16px is not on the ladder at all. */ + --font-size-xs: 10px; + --font-size-s: 14px; --font-size-m: 18px; --font-size-l: 20px; --font-size-xl: 24px; @@ -118,111 +192,3 @@ --motion-fast: 150ms ease-out; --motion-base: 200ms cubic-bezier(0.2, 0, 0, 1); } - -/* ============================================================ - * Base - * ============================================================ */ - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; -} - -body { - font-family: var(--font-family-default); - font-size: var(--font-size-m); - line-height: 1.5; - color: var(--text-neutral); - background: var(--surface-page); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - font-synthesis: none; -} - -#root { - min-height: 100svh; -} - -button { - font-family: inherit; -} - -/* ============================================================ - * Typography utility classes (mirror Figma named text styles) - * Sizes shift at the desktop breakpoint (≥1024px). - * ============================================================ */ - -.text-h1 { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-2xl); - line-height: 1.2; -} - -.text-h2 { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-xl); - line-height: 1.2; -} - -.text-h3 { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-l); - line-height: 1.2; -} - -.text-h4 { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-m); - line-height: 1.2; -} - -.text-body { - font-family: var(--font-family-default); - font-weight: 400; - font-size: var(--font-size-m); - line-height: 1.5; -} - -.text-body-s { - font-family: var(--font-family-default); - font-weight: 400; - font-size: var(--font-size-xs); - line-height: 1.4; -} - -.text-label-button { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-xs); - line-height: 1; - letter-spacing: 0.28px; -} - -.text-label-input { - font-family: var(--font-family-default); - font-weight: 500; - font-size: var(--font-size-s); - line-height: 1.4; -} - -@media (min-width: 1024px) { - .text-h1 { font-size: var(--font-size-3xl); } - .text-h2 { font-size: var(--font-size-2xl); } - .text-h3 { font-size: var(--font-size-xl); } - .text-h4 { font-size: var(--font-size-l); } - .text-body { font-size: var(--font-size-l); } - .text-body-s { font-size: var(--font-size-s); } - .text-label-button { font-size: var(--font-size-s); } -} diff --git a/src/ogcr-theme.css b/src/ogcr-theme.css index 3bd3762..be68869 100644 --- a/src/ogcr-theme.css +++ b/src/ogcr-theme.css @@ -1,9 +1,25 @@ /* ============================================================ * OGCR Theme * Anchored to the OGCR design system (Figma file 2P6XrQJhT8I39IR5LGK7RT). - * Mirrors the named tokens from ../../OGCR-design/ogcr-design-system/src/index.css - * and maps them onto Skeleton's --color-* scale so existing - * `preset-filled-primary-500` / `bg-surface-50` style classes still work. + * Mirrors the named DS tokens and maps them onto Skeleton's + * --color-* scale so existing `preset-filled-primary-500` / + * `bg-surface-50` style classes still work. + * + * Token values come from @majistudio/ogcr-design-system 1.1.0, pinned + * to upstream commit e947133 — see ./ogcr-design-system-reference.css + * for the full provenance block, and ../design_system_integration.md + * for why this app mirrors a React design system by hand and how to + * re-reconcile when upstream moves. + * + * TWO DIFFERENT THINGS LIVE IN THIS FILE: + * + * 1. The named DS token block (further down) is a 1:1 mirror of + * upstream values. Keep it exact. + * + * 2. The Skeleton --color-* ramps below are DERIVED, not mirrored: + * interpolated 11-step ramps with DS values pinned at particular + * steps (marked in comments). The in-between steps are ours and + * have no upstream counterpart — do not "reconcile" them. * ============================================================ */ [data-theme='ogcr'] { @@ -38,14 +54,14 @@ --anchor-text-decoration-focus: none; --body-background-color: var(--color-surface-50); --body-background-color-dark: var(--color-surface-950); - --radius-base: 8px; /* radius-m */ + --radius-base: 8px; /* radius-m */ --radius-container: 12px; /* radius-l */ --default-border-width: 1px; --default-divide-width: 1px; --default-ring-width: 1px; /* ----- Primary — OGCR Blue (anchored to brand-blue-300/800) -- */ - --color-primary-50: #eef4fa; + --color-primary-50: #eef4fa; --color-primary-100: #d4e3f1; --color-primary-200: #a9c7e3; --color-primary-300: #7eabd4; @@ -71,13 +87,16 @@ --color-primary-contrast-950: var(--color-primary-contrast-light); /* ----- Secondary — OGCR Green (anchored to brand-green-500) -- */ - --color-secondary-50: #eef7f1; + --color-secondary-50: #eef7f1; --color-secondary-100: #e2efe6; /* surface-positive */ --color-secondary-200: #c5dfce; /* border-positive-light */ --color-secondary-300: #9bc7ab; --color-secondary-400: #6db087; /* brand-green-500 */ - --color-secondary-500: #4f8263; /* interaction-primary-default */ - --color-secondary-600: #416c51; /* interaction-primary-hover / text-positive */ + --color-secondary-500: #4f8263; /* interaction-primary-default / text-positive */ + /* Ramp step only — as of DS 1.1.0 this matches no DS token. + interaction-primary-hover/active moved to #335641; code that + wants the real brand hover must use var(--interaction-primary-hover). */ + --color-secondary-600: #416c51; --color-secondary-700: #355743; --color-secondary-800: #284334; --color-secondary-900: #1c2f24; @@ -97,7 +116,7 @@ --color-secondary-contrast-950: var(--color-secondary-contrast-light); /* ----- Tertiary — Dark Teal --------------------------------- */ - --color-tertiary-50: #eaf2ee; + --color-tertiary-50: #eaf2ee; --color-tertiary-100: #cfe1d6; --color-tertiary-200: #9fc2ad; --color-tertiary-300: #6fa385; @@ -123,13 +142,13 @@ --color-tertiary-contrast-950: var(--color-tertiary-contrast-light); /* ----- Success (mirrors design-system positive) ------------- */ - --color-success-50: #eef7f1; + --color-success-50: #eef7f1; --color-success-100: #e2efe6; /* surface-positive */ --color-success-200: #c5dfce; /* border-positive-light */ --color-success-300: #9bc7ab; - --color-success-400: #6db087; - --color-success-500: #4f8263; - --color-success-600: #416c51; + --color-success-400: #6db087; /* brand-green-500 */ + --color-success-500: #4f8263; /* text-positive / icon-positive */ + --color-success-600: #416c51; /* ramp step only — see secondary-600 */ --color-success-700: #355743; --color-success-800: #284334; --color-success-900: #1c2f24; @@ -149,7 +168,7 @@ --color-success-contrast-950: var(--color-success-contrast-light); /* ----- Warning (mirrors design-system warning) -------------- */ - --color-warning-50: #fff7ed; + --color-warning-50: #fff7ed; --color-warning-100: #ffedd5; /* surface-warning */ --color-warning-200: #fed7aa; /* border-warning-light */ --color-warning-300: #fdba74; @@ -175,7 +194,7 @@ --color-warning-contrast-950: var(--color-warning-contrast-light); /* ----- Error (mirrors design-system negative) --------------- */ - --color-error-50: #fef2f2; + --color-error-50: #fef2f2; --color-error-100: #fee2e2; /* surface-negative */ --color-error-200: #fecaca; /* border-negative-light / focus-ring-error */ --color-error-300: #fca5a5; @@ -201,7 +220,7 @@ --color-error-contrast-950: var(--color-error-contrast-light); /* ----- Surface — warm scale anchored to surface-page -------- */ - --color-surface-50: #f8f3ef; /* surface-page */ + --color-surface-50: #f8f3ef; /* surface-page */ --color-surface-100: #f5f0eb; --color-surface-200: #e7e5e4; /* border-light */ --color-surface-300: #d6d3d1; /* border-medium */ @@ -228,9 +247,10 @@ /* ============================================================ * Design system named tokens - * Mirror OGCR-design/ogcr-design-system/src/index.css so code - * can reference --surface-page, --text-primary, --space-m, etc. - * directly without going through the Skeleton scale. + * A 1:1 mirror of the DS token values so code can reference + * --surface-page, --text-primary, --space-m, etc. directly without + * going through the Skeleton scale. Keep exact; the values and + * their provenance live in ./ogcr-design-system-reference.css. * ============================================================ */ /* Brand */ @@ -242,17 +262,23 @@ --text-primary: #0f3655; --text-secondary: #6a8196; --text-neutral: #334155; - --text-positive: #416c51; + --text-positive: #4f8263; --text-negative: #b91c1c; --text-warning: #c2410c; + --text-progress: #265277; /* Icon */ --icon-primary: #0f3655; --icon-secondary: #6a8196; --icon-neutral: #334155; - --icon-positive: #416c51; + --icon-positive: #4f8263; --icon-negative: #b91c1c; --icon-warning: #c2410c; + --icon-progress: #265277; + --icon-positive-light: #6db087; + --icon-negative-light: #ef4444; + --icon-warning-light: #f97316; + --icon-progress-light: #326d9e; /* Surface (named, for components that want exact DS tokens) */ --surface-page: #f8f3ef; @@ -263,6 +289,7 @@ --surface-positive: #e2efe6; --surface-warning: #ffedd5; --surface-negative: #fee2e2; + --surface-progress: #e2edf6; /* Border */ --border-light: #e7e5e4; @@ -274,13 +301,23 @@ --border-warning-light: #fed7aa; --border-negative-light: #fecaca; --border-negative-strong: #dc2626; + --border-neutral-strong: #57534e; + --border-positive-strong: #5e9975; + --border-warning-strong: #ea580c; /* Interaction */ --interaction-primary-default: #4f8263; - --interaction-primary-hover: #416c51; - --interaction-primary-active: #416c51; + --interaction-primary-hover: #335641; + --interaction-primary-active: #335641; --interaction-primary-focus: #e2efe6; - --interaction-secondary-focus: #e2d0bf; + --interaction-secondary-default: #ffffff; + --interaction-secondary-hover: #1c3d59; + --interaction-secondary-active: #1c3d59; + --interaction-secondary-focus: #c3daed; + --interaction-tertiary-active: #e2efe6; + + /* DEPRECATED — removed upstream in 1.1.0; use the --focus-error + shadow instead. Kept only because app code still reads it. */ --focus-ring-error: #fecaca; /* Spacing */ @@ -301,20 +338,19 @@ --radius-m: 8px; --radius-l: 12px; --radius-xl: 16px; - --radius-full: 999px; + --radius-full: 9999px; /* Elevation */ - --elevation-l: - 0 0 2px 0 rgba(68, 51, 33, 0.08), - 0 8px 16px 0 rgba(68, 51, 33, 0.16); + --elevation-l: 0 0 2px 0 rgba(68, 51, 33, 0.08), 0 8px 16px 0 rgba(68, 51, 33, 0.16); /* Typography */ --font-family-default: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-family-display: 'Helvetica Now Display', system-ui, -apple-system, sans-serif; --font-family-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; - --font-size-xs: 14px; - --font-size-s: 16px; + /* Ladder: 10/14/18/20/24/32/40/48/64 — 16px is not on it. */ + --font-size-xs: 10px; + --font-size-s: 14px; --font-size-m: 18px; --font-size-l: 20px; --font-size-xl: 24px; @@ -371,16 +407,16 @@ .text-body-s { font-family: var(--font-family-default); font-weight: 400; - font-size: var(--font-size-xs); + font-size: var(--font-size-s); line-height: 1.4; } .text-label-button { font-family: var(--font-family-default); font-weight: 500; - font-size: var(--font-size-xs); + font-size: var(--font-size-s); line-height: 1; - letter-spacing: 0.28px; + letter-spacing: 0.02em; /* resolves to the 0.28px components hard-code at 14px */ } .text-label-input { @@ -391,11 +427,25 @@ } @media (min-width: 1024px) { - .text-h1 { font-size: var(--font-size-3xl); } - .text-h2 { font-size: var(--font-size-2xl); } - .text-h3 { font-size: var(--font-size-xl); } - .text-h4 { font-size: var(--font-size-l); } - .text-body { font-size: var(--font-size-l); } - .text-body-s { font-size: var(--font-size-s); } - .text-label-button { font-size: var(--font-size-s); } + .text-h1 { + font-size: var(--font-size-3xl); + } + .text-h2 { + font-size: var(--font-size-2xl); + } + .text-h3 { + font-size: var(--font-size-xl); + } + .text-h4 { + font-size: var(--font-size-l); + } + .text-body { + font-size: var(--font-size-l); + } + .text-body-s { + font-size: var(--font-size-m); + } + .text-label-button { + font-size: var(--font-size-m); + } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f37a58b..142315c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -31,6 +31,8 @@ login: 'Login', logout: 'Logout', design: 'Design System', + registry: 'Registry', + certificates: 'Certificates', trading: 'Trading', banks: 'Bank', accounts: 'Account', @@ -57,7 +59,10 @@
-
+
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts deleted file mode 100644 index 7784034..0000000 --- a/src/routes/+page.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getChainSyncStatus } from '$lib/chain/onChain'; -import { deriveHeartbeat } from '$lib/chain/heartbeat'; - -export const load: PageServerLoad = async ({ locals }) => { - const accessToken = locals.session.data.oauth?.access_token; - if (!accessToken) { - return { heartbeat: null }; - } - - // The landing page should still render if the mirror is unreachable, so a - // failure here degrades to no indicator rather than an error page. - try { - const status = await getChainSyncStatus(accessToken); - return { heartbeat: deriveHeartbeat(status) }; - } catch { - return { heartbeat: null }; - } -}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9ebc9d4..31e08cb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,31 +1,70 @@ -
-
- OGCR -

Welcome to OGCR App

-

- An example application to interact with the OGCR entities -

-

- See Activities, - manage your Operators, - open Trading, - check the Chain, - or browse the Design System. -

+
+
+
+ OGCR +

Welcome to OGCR App

+

+ An example application to interact with the OGCR entities +

+
- {#if data.heartbeat} - -
- -
- {/if} + +
diff --git a/src/routes/activities/+page.svelte b/src/routes/activities/+page.svelte index e735180..952ae5c 100644 --- a/src/routes/activities/+page.svelte +++ b/src/routes/activities/+page.svelte @@ -27,9 +27,9 @@ summary?: string; image?: string; multipolygon_coordinates?: string | object; - type?: string; + activity_type?: string; city?: string; - country_code?: string; + country_id?: string; operator_name?: string | null; // Marketplace-owned listing terms (from the listings overlay); null until listed. price_per_credit?: number | string | null; @@ -64,7 +64,7 @@ } const countries = $derived( - [...new Set(activities.map((a) => a.country_code).filter(Boolean))].sort() as string[] + [...new Set(activities.map((a) => a.country_id).filter(Boolean))].sort() as string[] ); function num(v: number | string | null | undefined): number | null { @@ -75,11 +75,11 @@ const filtered = $derived( activities.filter((a) => { - const cat = categorizeActivityType(a.type); + const cat = categorizeActivityType(a.activity_type); if (query.trim()) { const q = query.trim().toLowerCase(); - const hay = [a.name, a.summary, a.type, a.operator_name, a.city, a.country_code] + const hay = [a.name, a.summary, a.activity_type, a.operator_name, a.city, a.country_id] .filter(Boolean) .join(' ') .toLowerCase(); @@ -87,7 +87,7 @@ } if (categoryKey !== 'all' && cat.key !== categoryKey) return false; - if (country !== 'all' && a.country_code !== country) return false; + if (country !== 'all' && a.country_id !== country) return false; // Verification status (see issue #1) is already applied server-side — // `activities` only contains rows matching the current verificationStatus. @@ -127,7 +127,7 @@ } function region(a: ActivityRow): string { - return [a.city, a.country_code].filter(Boolean).join(', ') || 'Unknown region'; + return [a.city, a.country_id].filter(Boolean).join(', ') || 'Unknown region'; } // Normalise an activity's coordinates into a GeoJSON object (or null). @@ -311,7 +311,7 @@ {:else}
{#each filtered as activity (activity.activity_id)} - {@const cat = categorizeActivityType(activity.type)} + {@const cat = categorizeActivityType(activity.activity_type)} {@const price = num(activity.price_per_credit)} {@const credits = num(activity.credits_available)} {@const geo = toGeoJson(activity.multipolygon_coordinates)} diff --git a/src/routes/activities/[id]/+page.server.ts b/src/routes/activities/[id]/+page.server.ts index 1638168..a639d32 100644 --- a/src/routes/activities/[id]/+page.server.ts +++ b/src/routes/activities/[id]/+page.server.ts @@ -11,6 +11,8 @@ import { ENTITY_ACTIVITY_PARCEL_VERIFICATION } from '$lib/constants/entities'; import { OBPRequestError } from '$lib/obp/errors'; +import { getCountries, type CountryRecord } from '$lib/reference/countries'; +import { getPractices, type PracticeRecord } from '$lib/reference/practices'; export const load: PageServerLoad = async ({ locals, params }) => { const session = locals.session; @@ -121,9 +123,26 @@ export const load: PageServerLoad = async ({ locals, params }) => { const parcels = await Promise.all(parcelPromises); + // Reference lists for the country and practice pickers in the edit form. + // Tolerant: a fetch failure leaves those fields as free text. + let countries: CountryRecord[] = []; + let practices: PracticeRecord[] = []; + try { + countries = await getCountries(accessToken); + } catch { + // No list available — the country field still accepts a typed ISO code. + } + try { + practices = await getPractices(accessToken); + } catch { + // No list available — the practice field still accepts a typed id. + } + return { isAuthenticated: true, activity, + countries, + practices, operatorId: operatorId ?? null, operatorName, activityVerifications, @@ -257,12 +276,11 @@ export const actions: Actions = { website: (formData.get('website') as string) ?? '', image: (formData.get('image') as string) ?? '', media_links: (formData.get('media_links') as string) ?? '', - technologies_practices_processes: - (formData.get('technologies_practices_processes') as string) ?? '', - type: (formData.get('type') as string) ?? '', + technologies_practices_processes_id: + (formData.get('technologies_practices_processes_id') as string) ?? '', + activity_type: (formData.get('activity_type') as string) ?? '', city: (formData.get('city') as string) ?? '', - country_code: (formData.get('country_code') as string) ?? '', - activity_plan_id: (formData.get('activity_plan_id') as string) ?? '', + country_id: (formData.get('country_id') as string) ?? '', start_date: (formData.get('start_date') as string) ?? '', end_date: (formData.get('end_date') as string) ?? '', cobenefits: (formData.get('cobenefits') as string) ?? '', @@ -281,11 +299,10 @@ export const actions: Actions = { website: values.website, image: values.image, media_links: values.media_links, - technologies_practices_processes: values.technologies_practices_processes, - type: values.type, + technologies_practices_processes_id: values.technologies_practices_processes_id, + activity_type: values.activity_type, city: values.city, - country_code: values.country_code, - activity_plan_id: values.activity_plan_id, + country_id: values.country_id, start_date: values.start_date, end_date: values.end_date, cobenefits: values.cobenefits, diff --git a/src/routes/activities/[id]/+page.svelte b/src/routes/activities/[id]/+page.svelte index 7678881..9e4356b 100644 --- a/src/routes/activities/[id]/+page.svelte +++ b/src/routes/activities/[id]/+page.svelte @@ -3,6 +3,8 @@ import { FolderKanban, Copy, Check, MapPin, ShieldCheck, Activity, ArrowLeft, Save, Plus, Building2 } from '@lucide/svelte'; import GeoJsonMap from '$lib/components/GeoJsonMap.svelte'; import { enhance } from '$app/forms'; + import ReferenceSelect from '$lib/components/ReferenceSelect.svelte'; + import { countryOptions, practiceOptions } from '$lib/reference/options'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -161,7 +163,7 @@ @@ -188,7 +197,14 @@ - + - - - -
@@ -232,12 +224,13 @@ diff --git a/src/routes/activities/list/+page.server.ts b/src/routes/activities/list/+page.server.ts index 565da22..2e26cfc 100644 --- a/src/routes/activities/list/+page.server.ts +++ b/src/routes/activities/list/+page.server.ts @@ -47,9 +47,9 @@ export const load: PageServerLoad = async ({ locals }) => { return { activity_id: a.activity_id, name: a.name, - type: a.type, + activity_type: a.activity_type, city: a.city, - country_code: a.country_code, + country_id: a.country_id, operator_id: a.operator_id, listed: listing?.listed ?? false, price_per_credit: listing?.price_per_credit ?? null, diff --git a/src/routes/activities/list/+page.svelte b/src/routes/activities/list/+page.svelte index c85e04e..b7b82e3 100644 --- a/src/routes/activities/list/+page.svelte +++ b/src/routes/activities/list/+page.svelte @@ -86,7 +86,7 @@
- {[activity.city, activity.country_code].filter(Boolean).join(', ') || 'Unknown'} + {[activity.city, activity.country_id].filter(Boolean).join(', ') || 'Unknown'} diff --git a/src/routes/activities/list/[id]/+page.svelte b/src/routes/activities/list/[id]/+page.svelte index 8aaa076..3479563 100644 --- a/src/routes/activities/list/[id]/+page.svelte +++ b/src/routes/activities/list/[id]/+page.svelte @@ -25,12 +25,12 @@ { label: 'Name', field: 'name' }, { label: 'Description', field: 'description' }, { label: 'Operator ID', field: 'operator_id' }, - { label: 'Type', field: 'type' }, - { label: 'Technologies, practices & processes', field: 'technologies_practices_processes' }, + { label: 'Type', field: 'activity_type' }, + { label: 'Technologies, practices & processes', field: 'technologies_practices_processes_id' }, { label: 'Cobenefits', field: 'cobenefits' }, { label: 'Methodologies', field: 'methodologies' }, { label: 'City', field: 'city' }, - { label: 'Country code', field: 'country_code' }, + { label: 'Country', field: 'country_id' }, { label: 'Start date', field: 'start_date' }, { label: 'End date', field: 'end_date' }, { label: 'Term commitment (years)', field: 'term_commitment' }, diff --git a/src/routes/backend/status/+server.ts b/src/routes/backend/status/+server.ts index 17ea5a4..821771c 100644 --- a/src/routes/backend/status/+server.ts +++ b/src/routes/backend/status/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { healthCheckRegistry, summarizeHealth } from '$lib/health-check'; +import { buildSystemStatus } from '$lib/server/systemStatus'; -export const GET: RequestHandler = async () => { - return json(summarizeHealth(healthCheckRegistry.getSnapshots())); +export const GET: RequestHandler = async ({ locals }) => { + return json(await buildSystemStatus(locals.session.data.oauth?.access_token)); }; diff --git a/src/routes/chain/+page.svelte b/src/routes/chain/+page.svelte index 7b798aa..f09123d 100644 --- a/src/routes/chain/+page.svelte +++ b/src/routes/chain/+page.svelte @@ -2,6 +2,8 @@ import type { PageData } from './$types'; import { Link2, RefreshCw, ExternalLink, HelpCircle } from '@lucide/svelte'; import ChainHeartbeat from '$lib/components/ChainHeartbeat.svelte'; + import Timestamp from '$lib/components/Timestamp.svelte'; + import { formatAge, HEARTBEAT_STATES } from '$lib/chain/heartbeat'; import { totalMirrored } from '$lib/chain/heartbeat'; import { explorerLinks, shortenHex } from '$lib/chain/explorer'; import { invalidateAll } from '$app/navigation'; @@ -70,7 +72,17 @@
-

Last mirror run

+
+

Last mirror run

+ {#if status?.synced_at} +
+ + {#if data.heartbeat.ageSeconds !== null} + · {formatAge(data.heartbeat.ageSeconds)} ago + {/if} +
+ {/if} +

Records written by OGCR-chain-cache on its most recent pass. {#if status?.mirrored_types} @@ -199,5 +211,33 @@

{/if}
+ + +
+

What the chain status means

+

+ The app does not talk to the chain directly. OGCR-chain-cache copies chain state into + OBP on a schedule and writes a sync record at the end of every run; the chain status is + read from that record. +

+
+ {#each Object.entries(HEARTBEAT_STATES) as [state, { label, meaning }] (state)} + {@const current = !data.error && state === data.heartbeat.state} +
+
+ {label} + {#if current}(current){/if} +
+
{meaning}
+
+ {/each} +
+
{/if}
diff --git a/src/routes/design/+page.svelte b/src/routes/design/+page.svelte index 89afbc8..763c4cb 100644 --- a/src/routes/design/+page.svelte +++ b/src/routes/design/+page.svelte @@ -1,4 +1,13 @@ Design system — OGCR -
+

OGCR Design System

- Live reference of the tokens defined in src/ogcr-theme.css. - Mirrors OGCR Design System + Live reference of the tokens defined in src/ogcr-theme.css. Mirrors + OGCR Design System (Figma) and the verbatim copy in src/ogcr-design-system-reference.css.

+ +
+

Integration status

+

+ The design system is React 19 + Base UI; this app is SvelteKit 5 + Skeleton 4, so none of its + components can be imported. We mirror its token values by hand and build the + components ourselves. The full record — why we do not install the package, what is pinned to + what, and how to re-reconcile — is in + design_system_integration.md. +

+ +
+
+
Upstream provenance
+
+ {#each provenance as row} +
+
{row.label}
+
+ {#if row.href} + + {row.value} + + {:else} + {row.value} + {/if} + {#if row.note} + — {row.note} + {/if} +
+
+ {/each} +
+

+ The pin that matters is the token commit, not the HEAD we happened to read: if nothing has + touched packages/design-system/src/styles/ since + e947133, these tokens are current however far upstream has moved. +

+
+ +
+
Checking for drift
+
npm run check:design-tokens
+

+ Fetches the published tarball for the pinned version and diffs every token against + src/ogcr-design-system-reference.css — all 62 colours plus the spacing, radius, + type, elevation, motion and font-family scales. Exit 0 in sync, 1 on drift, 2 if it could not + check (offline). It also flags a newer published version than our pin. +

+

+ On drift, four files move together — the mirror, the named-token block of + src/ogcr-theme.css, this page (the values here are display strings), and the + drift log in the document. The Skeleton --color-* ramps are + derived, not mirrored: only their anchor steps need to agree with upstream. +

+
+
+ +
+
+ Not adopted: the shipped stylesheet +
+

+ Importing styles.css would give us exact tokens, but upstream pins + --spacing to 1px globally, where this app uses + 0.25rem. That quarters all 1,192 numeric spacing classes across 37 of our 41 + Svelte files, and --spacing lives in @theme at the root, so it + cannot be scoped to one route. Two more changes land with it: + --font-size-m 16 → 18px (taking --text-h4 and + --text-body with it), and shadow-elevation-s never existed. +

+
+ +

+ Drift log — 2026-09-22, first reconciliation +

+

+ The previous mirror claimed to be a verbatim copy of a file that no longer exists upstream (index.css + was split into palette.css + theme.css). Diffing it against the real + palette: +

+
+ {#each driftSummary as d} +
+
{d.count}
+
{d.label}
+
{d.of}
+
+ {/each} +
+ +
+
Corrected values
+
+ {#each driftedTokens as t} +
+ {t.name} + + + {t.was} + + → + + + {t.now} + +
+ {/each} +
+

+ The green moves are upstream's deliberate active-green vs. selection-navy split; the last + row is a beige where the brand has light blue. 19 further tokens were simply absent here and + have been added (the icon-*-light set, the + interaction-secondary-* set, the *-strong borders, + surface-progress, scrollbar and z-index tokens). +

+
Non-colour drift
+
    + {#each nonColourDrift as item} +
  • {item}
  • + {/each} +
+
+
+

Brand

-
-
+
+
-
+
Brand blue 300
#3f88c6 · --brand-blue-300
-
+
-
+
Brand blue 800
#1c3d59 · --brand-blue-800
-
+
-
+
Brand green 500
#6db087 · --brand-green-500
@@ -128,7 +391,7 @@

Typography

-
+
text-h1 — The quick brown fox
text-h2 — The quick brown fox
text-h3 — The quick brown fox
@@ -140,9 +403,11 @@

Font sizes

-
+
{#each fontSizes as fs} -
+
Aa {fs.name} · {fs.size}
@@ -154,16 +419,18 @@

Colour scales (Skeleton)

- These are the scales available via Tailwind/Skeleton classes - (bg-primary-500, text-surface-700, …). Anchored to the design system tokens. + These are the scales available via Tailwind/Skeleton classes (bg-primary-500, + text-surface-700, …). Anchored to the design system tokens.

{#each skeletonScales as scale}
{scale.label}
{#each shades as shade} -
+
{shade}
{/each} @@ -177,9 +444,9 @@

Named tokens

Text

-
+
{#each dsTextTokens as t} -
+
Sample text
{t.name} · {t.value}
@@ -187,11 +454,11 @@

Surface

-
+
{#each dsSurfaceTokens as t} -
+
-
+
{t.name}
{t.value}
@@ -200,9 +467,9 @@

Border

-
+
{#each dsBorderTokens as t} -
+
{t.name}
{t.value}
@@ -210,11 +477,11 @@

Interaction

-
+
{#each dsInteractionTokens as t} -
+
-
+
{t.name}
{t.value}
@@ -240,10 +507,12 @@

Radius

-
+
{#each radii as r} -
-
+
+
{r.name}
{r.size}
@@ -254,12 +523,12 @@

Elevation

-
-
+
+
Elevation L
--elevation-l
-
+
No elevation
(border only)
@@ -269,7 +538,9 @@

Buttons (Skeleton)

-
+
@@ -277,7 +548,9 @@
-
+
@@ -285,10 +558,12 @@
-
- +
+ - +
@@ -296,7 +571,9 @@

Form elements

-
+
diff --git a/src/routes/operators/+page.server.ts b/src/routes/operators/+page.server.ts index 10d10d4..605c82d 100644 --- a/src/routes/operators/+page.server.ts +++ b/src/routes/operators/+page.server.ts @@ -7,7 +7,7 @@ import { OBPRequestError } from '$lib/obp/errors'; export interface OperatorSummary { operator_id: string; legal_name: string | null; - country_code: string | null; + country_id: string | null; } export const load: PageServerLoad = async ({ locals }) => { @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ locals }) => { .map((r) => ({ operator_id: r.operator_id as string, legal_name: typeof r.legal_name === 'string' ? r.legal_name : null, - country_code: typeof r.country_code === 'string' ? r.country_code : null + country_id: typeof r.country_id === 'string' ? r.country_id : null })) .sort((a, b) => (a.legal_name ?? '').localeCompare(b.legal_name ?? '')); diff --git a/src/routes/operators/+page.svelte b/src/routes/operators/+page.svelte index 8164db7..cea598f 100644 --- a/src/routes/operators/+page.svelte +++ b/src/routes/operators/+page.svelte @@ -43,7 +43,7 @@

{op.legal_name || op.operator_id}

- {op.country_code || 'Unknown country'} + {op.country_id || 'Unknown country'}

diff --git a/src/routes/operators/[operatorId]/+page.server.ts b/src/routes/operators/[operatorId]/+page.server.ts index 4e7e7b6..1f701f2 100644 --- a/src/routes/operators/[operatorId]/+page.server.ts +++ b/src/routes/operators/[operatorId]/+page.server.ts @@ -12,7 +12,7 @@ import { getOperatorsForUserId } from '$lib/marketplace/ownership'; export interface PublicOperator { operator_id: string; legal_name: string | null; - country_code: string | null; + country_id: string | null; ogcr_wallet_address: string | null; } @@ -27,9 +27,9 @@ export interface OperatorContact { export interface OperatorActivity { activity_id: string; name: string | null; - type: string | null; + activity_type: string | null; city: string | null; - country_code: string | null; + country_id: string | null; } function str(record: Record, key: string): string | null { @@ -60,7 +60,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { const operator: PublicOperator = { operator_id: operatorId, legal_name: str(record, 'legal_name'), - country_code: str(record, 'country_code'), + country_id: str(record, 'country_id'), ogcr_wallet_address: str(record, 'ogcr_wallet_address') }; @@ -102,9 +102,9 @@ export const load: PageServerLoad = async ({ locals, params }) => { .map((a) => ({ activity_id: a.activity_id as string, name: str(a, 'name'), - type: str(a, 'type'), + activity_type: str(a, 'activity_type'), city: str(a, 'city'), - country_code: str(a, 'country_code') + country_id: str(a, 'country_id') })); } catch { // Activities unavailable — the page shows the operator without them. diff --git a/src/routes/operators/[operatorId]/+page.svelte b/src/routes/operators/[operatorId]/+page.svelte index 432a767..75c567a 100644 --- a/src/routes/operators/[operatorId]/+page.svelte +++ b/src/routes/operators/[operatorId]/+page.svelte @@ -8,8 +8,8 @@ const contact = $derived(data.contact); const activities = $derived(data.activities ?? []); - function region(a: { city: string | null; country_code: string | null }): string { - return [a.city, a.country_code].filter(Boolean).join(', ') || 'Unknown region'; + function region(a: { city: string | null; country_id: string | null }): string { + return [a.city, a.country_id].filter(Boolean).join(', ') || 'Unknown region'; } @@ -61,7 +61,7 @@
Country
- {operator.country_code || '—'} + {operator.country_id || '—'}
@@ -131,7 +131,7 @@ class="card p-4 preset-filled-surface-100-900 hover:preset-tonal transition-colors block" >

{activity.name || 'Unnamed Activity'}

-

{activity.type || 'Unknown type'}

+

{activity.activity_type || 'Unknown type'}

{region(activity)} diff --git a/src/routes/registry/+page.server.ts b/src/routes/registry/+page.server.ts new file mode 100644 index 0000000..84ff569 --- /dev/null +++ b/src/routes/registry/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { getRegistryActivities } from '$lib/registry/activities'; + +/** + * The registry is a public surface, so this page takes no access token: the activity + * count comes from the same public endpoint the activity list uses, and a signed-out + * visitor sees the same figure a buyer would. + * + * Issued / Active / Retired units have no source yet. There is no issuance or + * retirement data in the DCR schema, and the only credit quantities anywhere + * (marketplace listings) are operator-entered commercial terms, not registry facts — + * per design_goals.md the registry owns the activity's facts. Those tiles say so + * rather than showing a fabricated number. + */ +export const load: PageServerLoad = async ({ fetch }) => { + const { count, error } = await getRegistryActivities(fetch); + return { activityCount: error ? null : count, error }; +}; diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte new file mode 100644 index 0000000..8f7f181 --- /dev/null +++ b/src/routes/registry/+page.svelte @@ -0,0 +1,126 @@ + + + + Registry — OGCR + + + +

+
+
+ +

OGCR — Open Geospatial Carbon Registry

+

+ The OGCR registry lists carbon removal and carbon farming activities certified under the EU + Carbon Removals and Carbon Farming framework (CRCF). Each activity records the land it + covers, the practices applied, the operator responsible and the monitoring period over which + its results are measured. +

+

+ Certification is carried out by accredited bodies and recorded against the activity, so a + buyer can trace a unit back to the parcel, the practice and the verification behind it. +

+ Browse Activities +
+ +
+ {#if HERO_IMAGE} + {HERO_ALT} + {:else} + +
+ Hero image +
+ {/if} +
+
+ +
+

Registry summary

+
+ + {#snippet icon()} + + {/snippet} + + + {#each unitStats as stat (stat.label)} + + {#snippet icon()} + + {/snippet} + + {/each} +
+ + {#if data.error} +

+ The activity count could not be loaded: {data.error} +

+ {/if} +
+
+ + diff --git a/src/routes/registry/activities/+page.server.ts b/src/routes/registry/activities/+page.server.ts new file mode 100644 index 0000000..45a3159 --- /dev/null +++ b/src/routes/registry/activities/+page.server.ts @@ -0,0 +1,16 @@ +import type { PageServerLoad } from './$types'; +import { getRegistryActivities } from '$lib/registry/activities'; + +/** + * Public: no access token. One call to the registry Dynamic Resource Doc, which has + * already joined operator, country, certificate and verification server-side. + * + * The whole list is fetched once and filtered/sorted in the browser. That is right + * while the registry is small, and it keeps filtering instant. If this ever holds the + * "hundreds of activities" the brief anticipates, push search/filter/sort down into the + * resource doc as query parameters rather than paginating client-side. + */ +export const load: PageServerLoad = async ({ fetch }) => { + const { activities, count, error } = await getRegistryActivities(fetch); + return { activities, count, error }; +}; diff --git a/src/routes/registry/activities/+page.svelte b/src/routes/registry/activities/+page.svelte new file mode 100644 index 0000000..0f8d514 --- /dev/null +++ b/src/routes/registry/activities/+page.svelte @@ -0,0 +1,287 @@ + + + + Activities — OGCR Registry + + + +
+

Activities

+ + {#if data.error} +
+ +
+ {/if} + +
+ + + + + + + + + {#if filtersApplied} + + {/if} +
+ +

+ {filtered.length === data.count + ? `${data.count} ${data.count === 1 ? 'activity' : 'activities'}` + : `${filtered.length} of ${data.count} activities`} +

+ + + {#snippet cell(row, column)} + {#if column.key === 'name'} + + {show(row.name)} + {:else if column.key === 'summary'} + + {show(row.summary)} + + {:else if column.key === 'activity_type'} + {#if row.activity_type} + {row.activity_type} + {:else} + {DASH} + {/if} + {:else if column.key === 'verification_status'} + + + {row.verification_status === 'verified' ? 'Verified' : 'Unverified'} + + {:else if column.key === 'certificate'} + {#if row.certificate_of_compliance_id} + + View certificate + + {:else} + Not yet certified + {/if} + {:else} + {show(row[column.key])} + {/if} + {/snippet} +
+
+ + diff --git a/src/routes/registry/certificates/[id]/+page.server.ts b/src/routes/registry/certificates/[id]/+page.server.ts new file mode 100644 index 0000000..af5a57f --- /dev/null +++ b/src/routes/registry/certificates/[id]/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { getRegistryActivities, type RegistryActivity } from '$lib/registry/activities'; + +/** + * Minimal certificate view. The brief keeps this deliberately small: enough to make + * the registry's Certificate column a working link, not a full certificate profile. + * + * There is no public endpoint for a single certificate, so this reads the registry + * activities and picks the matching row — the registry endpoint already carries the + * certificate's own fields. When a certificate detail endpoint exists, this loader is + * the only thing that changes. + */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const { activities, error } = await getRegistryActivities(fetch); + const activity = + activities.find((a: RegistryActivity) => a.certificate_of_compliance_id === params.id) ?? null; + return { certificateId: params.id, activity, error }; +}; diff --git a/src/routes/registry/certificates/[id]/+page.svelte b/src/routes/registry/certificates/[id]/+page.svelte new file mode 100644 index 0000000..7122d26 --- /dev/null +++ b/src/routes/registry/certificates/[id]/+page.svelte @@ -0,0 +1,133 @@ + + + + Certificate — OGCR Registry + + +
+
+ +

Certificate of Compliance

+
+ + {#if data.error} + + {:else if !activity} +

+ No certificate found with ID {data.certificateId}. It may have been withdrawn, or + the registry may not list the activity it belongs to. +

+

Back to activities

+ {:else} +
+ {#each fields as field (field.label)} +
+
{field.label}
+
+ {#if field.label === 'Certification status' && field.value !== DASH} + {field.value} + {:else} + {field.value} + {/if} +
+
+ {/each} +
+ +

Back to activities

+ {/if} +
+ + diff --git a/src/routes/status/+page.server.ts b/src/routes/status/+page.server.ts index c02de1a..5ee1eee 100644 --- a/src/routes/status/+page.server.ts +++ b/src/routes/status/+page.server.ts @@ -1,49 +1,6 @@ import type { PageServerLoad } from './$types'; -import { healthCheckRegistry, summarizeHealth } from '$lib/health-check'; -import { getChainSyncStatus, getTokenizationBacklog } from '$lib/chain/onChain'; -import type { Backlog } from '$lib/chain/backlog'; -import { deriveHeartbeat } from '$lib/chain/heartbeat'; -import { chainMirrorSnapshot, chainMirrorUncheckable } from '$lib/chain/mirrorHealth'; +import { buildSystemStatus } from '$lib/server/systemStatus'; export const load: PageServerLoad = async ({ locals }) => { - const snapshots = { ...healthCheckRegistry.getSnapshots() }; - const accessToken = locals.session.data.oauth?.access_token; - - // The chain mirror cannot be polled in the background like the other checks: - // reading its status record needs OBP credentials, which only a request - // carries. So it is evaluated here, per request, and only when we have a - // token to do it with. Adding an always-unknown entry for logged-out - // visitors would drag the whole page's overall status to unknown while - // telling them nothing. - let backlog: Backlog | null = null; - let backlogError: string | null = null; - - if (accessToken) { - try { - const status = await getChainSyncStatus(accessToken); - const snapshot = chainMirrorSnapshot(deriveHeartbeat(status)); - snapshots[snapshot.service] = snapshot; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - const snapshot = chainMirrorUncheckable(`Could not read the chain mirror: ${message}`); - snapshots[snapshot.service] = snapshot; - } - - // Deliberately NOT folded into summarizeHealth. A backlog is a measurement, - // not a health state: a snapshot cannot tell a queue being worked through - // from one that is stuck, so forcing it into healthy/unhealthy would either - // cry wolf on normal lag or hide a genuinely stalled tokenizer. - try { - backlog = await getTokenizationBacklog(accessToken); - } catch (error) { - backlogError = error instanceof Error ? error.message : 'Unknown error'; - } - } - - return { - ...summarizeHealth(snapshots), - chainMirrorChecked: !!accessToken, - backlog, - backlogError - }; + return buildSystemStatus(locals.session.data.oauth?.access_token); };