From 420ba7c8f86eed508c87da157f25b70a3f21ccb4 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 11 Sep 2026 09:34:46 +0200 Subject: [PATCH 1/8] backlog of chain loop --- chain_integration.md | 13 +++++--- src/lib/components/ChainHeartbeat.svelte | 3 +- src/lib/components/Timestamp.svelte | 22 +++++++++++++ src/lib/utils/datetime.test.ts | 33 +++++++++++++++++++ src/lib/utils/datetime.ts | 42 ++++++++++++++++++++++++ src/routes/chain/+page.svelte | 14 +++++++- 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 src/lib/components/Timestamp.svelte create mode 100644 src/lib/utils/datetime.test.ts create mode 100644 src/lib/utils/datetime.ts 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/src/lib/components/ChainHeartbeat.svelte b/src/lib/components/ChainHeartbeat.svelte index ce128f8..b27c251 100644 --- a/src/lib/components/ChainHeartbeat.svelte +++ b/src/lib/components/ChainHeartbeat.svelte @@ -1,6 +1,7 @@ + +{#if display} + +{:else} + — +{/if} 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/routes/chain/+page.svelte b/src/routes/chain/+page.svelte index 7b798aa..daca6aa 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 } 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} From 7b85cae7dde95c3d5cac9dd0e772d62220313894 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 21 Sep 2026 20:57:13 +0200 Subject: [PATCH 2/8] replacing country code with country id, activity_type, technologies_practices_processes_id --- src/lib/components/ReferenceSelect.svelte | 37 ++++++++++++++++ src/lib/constants/entities.ts | 8 ++++ src/lib/reference/countries.test.ts | 41 ++++++++++++++++++ src/lib/reference/countries.ts | 26 +++++++++++ src/lib/reference/options.test.ts | 20 +++++++++ src/lib/reference/options.ts | 38 ++++++++++++++++ src/lib/reference/practices.test.ts | 41 ++++++++++++++++++ src/lib/reference/practices.ts | 32 ++++++++++++++ src/routes/activities/+page.svelte | 16 +++---- src/routes/activities/[id]/+page.server.ts | 35 +++++++++++---- src/routes/activities/[id]/+page.svelte | 29 +++++++++---- src/routes/activities/create/+page.server.ts | 37 +++++++++++----- src/routes/activities/create/+page.svelte | 43 ++++++++----------- src/routes/activities/list/+page.server.ts | 4 +- src/routes/activities/list/+page.svelte | 2 +- src/routes/activities/list/[id]/+page.svelte | 6 +-- src/routes/my/operators/+page.svelte | 4 +- .../my/operators/[operatorId]/+page.server.ts | 19 ++++++-- .../my/operators/[operatorId]/+page.svelte | 13 +++++- .../my/operators/create/+page.server.ts | 17 ++++++-- src/routes/my/operators/create/+page.svelte | 13 +++++- src/routes/operators/+page.server.ts | 4 +- src/routes/operators/+page.svelte | 2 +- .../operators/[operatorId]/+page.server.ts | 12 +++--- .../operators/[operatorId]/+page.svelte | 8 ++-- 25 files changed, 414 insertions(+), 93 deletions(-) create mode 100644 src/lib/components/ReferenceSelect.svelte create mode 100644 src/lib/reference/countries.test.ts create mode 100644 src/lib/reference/countries.ts create mode 100644 src/lib/reference/options.test.ts create mode 100644 src/lib/reference/options.ts create mode 100644 src/lib/reference/practices.test.ts create mode 100644 src/lib/reference/practices.ts 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/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/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/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/my/operators/+page.svelte b/src/routes/my/operators/+page.svelte index 8ac8cec..2ead67b 100644 --- a/src/routes/my/operators/+page.svelte +++ b/src/routes/my/operators/+page.svelte @@ -80,10 +80,10 @@ {op.email}
{/if} - {#if op.country_code} + {#if op.country_id}
- {op.country_code} + {op.country_id}
{/if} {#if op.ogcr_wallet_address} diff --git a/src/routes/my/operators/[operatorId]/+page.server.ts b/src/routes/my/operators/[operatorId]/+page.server.ts index 1595f33..7af523f 100644 --- a/src/routes/my/operators/[operatorId]/+page.server.ts +++ b/src/routes/my/operators/[operatorId]/+page.server.ts @@ -3,6 +3,7 @@ import { obp_requests } from '$lib/obp/requests'; import { ENTITY_OPERATOR } from '$lib/constants/entities'; import { OBPRequestError } from '$lib/obp/errors'; import { getOperatorsForUserId, updateUserOperatorRelationship } from '$lib/marketplace/ownership'; +import { getCountries, type CountryRecord } from '$lib/reference/countries'; export const load: PageServerLoad = async ({ locals, params }) => { const session = locals.session; @@ -13,25 +14,35 @@ export const load: PageServerLoad = async ({ locals, params }) => { return { isAuthenticated: false, owned: false, operator: null }; } + // The country picker offers country_name and submits country_id. Tolerant: if + // the reference list can't be fetched the form falls back to a free-text code. + let countries: CountryRecord[] = []; + try { + countries = await getCountries(accessToken); + } catch { + // No list available — the country field still accepts a typed ISO code. + } + try { // Resolve the user's operators; ownership = the target is among them. // The resolved record already carries the operator fields + relationship. const operators = await getOperatorsForUserId(accessToken, userId); const operator = operators.find((o) => o.operator_id === params.operatorId) ?? null; - return { isAuthenticated: true, owned: !!operator, operator }; + return { isAuthenticated: true, owned: !!operator, operator, countries }; } catch (error) { if (error instanceof OBPRequestError) { return { isAuthenticated: true, owned: false, operator: null, + countries, error: error.message, errorDetails: error.toJSON() }; } const message = error instanceof Error ? error.message : 'Unknown error'; - return { isAuthenticated: true, owned: false, operator: null, error: message }; + return { isAuthenticated: true, owned: false, operator: null, countries, error: message }; } }; @@ -54,7 +65,7 @@ export const actions: Actions = { address_line_1: (formData.get('address_line_1') as string) ?? '', address_line_2: (formData.get('address_line_2') as string) ?? '', postcode: (formData.get('postcode') as string) ?? '', - country_code: (formData.get('country_code') as string) ?? '', + country_id: (formData.get('country_id') as string) ?? '', ogcr_wallet_address: (formData.get('ogcr_wallet_address') as string) ?? '', relationship: (formData.get('relationship') as string) ?? '' }; @@ -78,7 +89,7 @@ export const actions: Actions = { address_line_1: values.address_line_1, address_line_2: values.address_line_2, postcode: values.postcode, - country_code: values.country_code, + country_id: values.country_id, ogcr_wallet_address: values.ogcr_wallet_address }; for (const key of Object.keys(body)) { diff --git a/src/routes/my/operators/[operatorId]/+page.svelte b/src/routes/my/operators/[operatorId]/+page.svelte index 119da10..fa20c63 100644 --- a/src/routes/my/operators/[operatorId]/+page.svelte +++ b/src/routes/my/operators/[operatorId]/+page.svelte @@ -3,6 +3,8 @@ import { enhance } from '$app/forms'; import { page } from '$app/state'; import { Building2, ArrowLeft, Lock, CheckCircle2 } from '@lucide/svelte'; + import ReferenceSelect from '$lib/components/ReferenceSelect.svelte'; + import { countryOptions } from '$lib/reference/options'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -100,8 +102,15 @@
diff --git a/src/routes/my/operators/create/+page.server.ts b/src/routes/my/operators/create/+page.server.ts index 42fa378..45b2363 100644 --- a/src/routes/my/operators/create/+page.server.ts +++ b/src/routes/my/operators/create/+page.server.ts @@ -3,6 +3,7 @@ import { obp_requests } from '$lib/obp/requests'; import { ENTITY_OPERATOR } from '$lib/constants/entities'; import { OBPRequestError } from '$lib/obp/errors'; import { linkUserToOperator } from '$lib/marketplace/ownership'; +import { getCountries, type CountryRecord } from '$lib/reference/countries'; export const load: PageServerLoad = async ({ locals }) => { const session = locals.session; @@ -12,10 +13,20 @@ export const load: PageServerLoad = async ({ locals }) => { return { isAuthenticated: false }; } + // The country picker offers country_name and submits country_id. Tolerant: if + // the reference list can't be fetched the form falls back to a free-text code. + let countries: CountryRecord[] = []; + try { + countries = await getCountries(accessToken); + } catch { + // No list available — the country field still accepts a typed ISO code. + } + // Default the operator email to the logged-in user's email as a convenience. return { isAuthenticated: true, - userEmail: session.data.user?.email ?? '' + userEmail: session.data.user?.email ?? '', + countries }; }; @@ -42,7 +53,7 @@ export const actions: Actions = { address_line_1: (formData.get('address_line_1') as string) ?? '', address_line_2: (formData.get('address_line_2') as string) ?? '', postcode: (formData.get('postcode') as string) ?? '', - country_code: (formData.get('country_code') as string) ?? '', + country_id: (formData.get('country_id') as string) ?? '', ogcr_wallet_address: (formData.get('ogcr_wallet_address') as string) ?? '', relationship: (formData.get('relationship') as string) ?? '' }; @@ -55,7 +66,7 @@ export const actions: Actions = { address_line_1: values.address_line_1, address_line_2: values.address_line_2, postcode: values.postcode, - country_code: values.country_code, + country_id: values.country_id, ogcr_wallet_address: values.ogcr_wallet_address }; for (const key of Object.keys(body)) { diff --git a/src/routes/my/operators/create/+page.svelte b/src/routes/my/operators/create/+page.svelte index fc42340..8422626 100644 --- a/src/routes/my/operators/create/+page.svelte +++ b/src/routes/my/operators/create/+page.svelte @@ -2,6 +2,8 @@ import type { PageData, ActionData } from './$types'; import { enhance } from '$app/forms'; import { Building2, ArrowLeft, CheckCircle2, AlertTriangle } from '@lucide/svelte'; + import ReferenceSelect from '$lib/components/ReferenceSelect.svelte'; + import { countryOptions } from '$lib/reference/options'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -112,8 +114,15 @@ 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)} From 0374c4f9d97f8fce204ec224d6088570b68b006c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 22 Sep 2026 09:01:39 +0200 Subject: [PATCH 3/8] Design system pull / update --- README.md | 73 ++++++++- design_system_integration.md | 230 +++++++++++++++++++++++++++ package.json | 1 + scripts/check-design-tokens.mjs | 211 ++++++++++++++++++++++++ src/ogcr-design-system-reference.css | 228 +++++++++++--------------- src/ogcr-theme.css | 76 ++++++--- src/routes/design/+page.svelte | 34 ++-- 7 files changed, 691 insertions(+), 162 deletions(-) create mode 100644 design_system_integration.md create mode 100755 scripts/check-design-tokens.mjs 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/design_system_integration.md b/design_system_integration.md new file mode 100644 index 0000000..05380ff --- /dev/null +++ b/design_system_integration.md @@ -0,0 +1,230 @@ +# 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 +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`. + +| Upstream component | Spec | Status here | +| --- | --- | --- | +| Card | §4.3 | not yet ported | +| Pill | §4.6 | not yet ported | +| Message | §4.9 | not yet ported | +| KPI | §4.11 | not yet ported | +| Table | §4.14 | not yet ported | +| 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 yet ported | +| Pagination | §4.27 | not yet ported | +| Tooltip | §4.34 | not yet ported — build on `bits-ui` | + +### 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/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..68e61f1 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'] { @@ -76,8 +92,11 @@ --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; @@ -127,9 +146,9 @@ --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; @@ -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,7 +338,7 @@ --radius-m: 8px; --radius-l: 12px; --radius-xl: 16px; - --radius-full: 999px; + --radius-full: 9999px; /* Elevation */ --elevation-l: @@ -313,8 +350,9 @@ --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; diff --git a/src/routes/design/+page.svelte b/src/routes/design/+page.svelte index 89afbc8..4891ed8 100644 --- a/src/routes/design/+page.svelte +++ b/src/routes/design/+page.svelte @@ -1,4 +1,7 @@ -

-
- 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} +
+

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
@@ -140,7 +391,7 @@

Typography

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

Font sizes

-
+
{#each fontSizes as fs} -
+
Aa {fs.name} · {fs.size}
@@ -166,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} @@ -189,9 +444,9 @@

Named tokens

Text

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

Surface

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

Border

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

Interaction

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

Radius

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

Elevation

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

Buttons (Skeleton)

-
+
@@ -289,7 +548,9 @@
-
+
@@ -297,10 +558,12 @@
-
- +
+ - +
@@ -308,7 +571,9 @@

Form elements

-
+