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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# OGCR App - Claude Code Notes

## Always use the OGCR Design System

Every piece of UI in this app must use the OGCR Design System. This is a requirement,
not a preference. How the app consumes it (token mirror, ported components, porting
conventions) is in `design_system_integration.md`; the live reference is `/design`.

- **Colour, type, spacing, radius, elevation:** use the design system's named tokens
from `src/ogcr-theme.css` (`var(--text-primary)`, `var(--text-secondary)`,
`var(--surface-light)`, `var(--space-m)`, `var(--radius-l)`, the `text-h1` / `text-body`
classes, ...). Do not pick a colour from the Skeleton ramps (`text-surface-600-400`,
`bg-primary-300`, ...): most ramp steps are interpolated and match no design system
token. Never hard-code hex values or pixel sizes that a token already covers.
- **Components:** use the ported components in `src/lib/components/` (`Card`, `Pill`,
`Kpi`, `Table`) rather than hand-rolling the same thing. If the design system has a
component that is not ported yet, port it (see "Porting conventions" in
`design_system_integration.md`) rather than inventing a look.
- **No design system answer?** Ask before inventing one. Dark mode is the documented
exception: the design system has no dark palette, so components add a
`:global([data-mode='dark'])` block using the Skeleton dark surfaces.
- **Existing code:** much of the app predates this rule (for example, around 180 uses of
`text-surface-600-400`). Bring the lines you are already changing onto design system
tokens, but do not sweep files the task does not touch.

## OBP API Dynamic Endpoints Discovery

Dynamic entity structures change over time. Always fetch the current documentation before working with endpoints.
Expand Down
11 changes: 9 additions & 2 deletions design_system_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Everything in this document exists so that mirroring is a _checkable_ operation
than a copy that silently rots — which is exactly what happened to the previous copy
(see the [drift log](#drift-log)).

**Using the design system is mandatory in this app.** All UI takes its colours, type,
spacing, radii and elevation from the named DS tokens and uses the ported components
below. Skeleton ramp steps that are not DS anchors are not a substitute. The working rules
are in `CLAUDE.md` under _Always use the OGCR Design System_.

## Upstream provenance (what we are pinned to)

| | |
Expand Down Expand Up @@ -182,6 +187,7 @@ the two in step.

| Upstream component | Spec | Status here |
| ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Button | §4.1 | **ported** — `src/lib/components/Button.svelte`. Adds `href` (renders an anchor) and drops `select-none`; icon-only not yet ported |
| 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 |
Expand Down Expand Up @@ -217,8 +223,9 @@ the header trail.
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.
- **Document any API extension** in the component header. `Card` has two: `href` (makes
the title a link; only the title, so the rest of the card stays selectable text) and a
`leading` snippet for an icon.

### A trap in the spec

Expand Down
200 changes: 200 additions & 0 deletions src/lib/components/Button.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<script lang="ts">
/**
* OGCR Design System — Button (spec §4.1).
*
* Ported from the shipped component (packages/design-system/src/components/Button),
* following the conventions in ../../../design_system_integration.md: its Tailwind
* utility strings expressed as scoped CSS over our named tokens, class names
* namespaced `ogcr-`, and a dark-mode block because the design system has no dark
* palette.
*
* Sizes: s 32px, m 40px, l 48px (the spec height of `filled` and `outlined`, and
* their default); `text` defaults to m.
*
* Two deliberate differences from upstream:
* - `href` renders an anchor with the button's look, for navigation that should
* read as a button (upstream is always a <button>).
* - No `select-none`: the label stays selectable, like all text in this app.
* Icon-only buttons are not ported yet.
*/
import type { Snippet } from 'svelte';

type Variant = 'filled' | 'outlined' | 'text';
type Size = 's' | 'm' | 'l';

let {
variant = 'filled',
size,
fullWidth = false,
href,
type = 'button',
iconLeft,
iconRight,
children,
class: className = '',
...rest
}: {
variant?: Variant;
size?: Size;
fullWidth?: boolean;
href?: string;
type?: 'button' | 'submit' | 'reset';
iconLeft?: Snippet;
iconRight?: Snippet;
children?: Snippet;
class?: string;
[key: string]: unknown;
} = $props();

const resolvedSize = $derived(size ?? (variant === 'text' ? 'm' : 'l'));
const classes = $derived(
`ogcr-button ogcr-button--${variant} ogcr-button--${resolvedSize} ${fullWidth ? 'ogcr-button--full' : ''} ${className}`
);
</script>

{#snippet inner()}
{#if iconLeft}
<span class="ogcr-button__icon" aria-hidden="true">{@render iconLeft()}</span>
{/if}
{#if children}
<span class="ogcr-button__label">{@render children()}</span>
{/if}
{#if iconRight}
<span class="ogcr-button__icon" aria-hidden="true">{@render iconRight()}</span>
{/if}
{/snippet}

{#if href}
<a {href} class={classes} {...rest}>{@render inner()}</a>
{:else}
<button {type} class={classes} {...rest}>{@render inner()}</button>
{/if}

<style>
.ogcr-button {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-default);
font-weight: 500;
font-size: var(--font-size-s);
line-height: 1;
letter-spacing: 0.28px;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition:
background-color var(--motion-fast),
border-color var(--motion-fast),
box-shadow var(--motion-fast),
transform var(--motion-fast);
}
.ogcr-button:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--surface-light),
0 0 0 4px var(--interaction-primary-default);
}
.ogcr-button:active {
transform: translateY(1px);
}
.ogcr-button:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
}

.ogcr-button--filled {
background: var(--interaction-primary-default);
color: var(--surface-page);
}
.ogcr-button--filled:hover:not(:disabled) {
background: var(--interaction-primary-hover);
}
.ogcr-button--filled:active:not(:disabled) {
background: var(--interaction-primary-active);
}

.ogcr-button--outlined {
background: var(--surface-light);
border-color: var(--border-medium);
color: var(--text-primary);
}
.ogcr-button--outlined:hover:not(:disabled) {
background: var(--surface-neutral);
border-color: var(--border-strong);
}

.ogcr-button--text {
background: transparent;
color: var(--text-primary);
}
.ogcr-button--text:hover:not(:disabled) {
background: var(--surface-neutral);
}

.ogcr-button--s {
height: 32px;
gap: var(--space-xs);
padding: 0 var(--space-xs);
border-radius: var(--radius-m);
}
.ogcr-button--m {
height: 40px;
gap: var(--space-xs);
padding: 0 var(--space-s);
border-radius: var(--radius-m);
}
.ogcr-button--l {
height: 48px;
gap: var(--space-s);
padding: 0 var(--space-m);
border-radius: var(--radius-l);
}
.ogcr-button--full {
width: 100%;
}

/* Icon box: 24px at l (spec §4.1), 20px below it (spec §5). */
.ogcr-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
}
.ogcr-button--l .ogcr-button__icon {
width: 24px;
height: 24px;
}
.ogcr-button__icon :global(svg) {
width: 100%;
height: 100%;
}
.ogcr-button__label {
display: inline-flex;
align-items: center;
white-space: nowrap;
}

/* See Card.svelte — the design system ships no dark palette. */
:global([data-mode='dark']) .ogcr-button--outlined {
background: var(--color-surface-900);
border-color: var(--color-surface-700);
color: var(--color-primary-200);
}
:global([data-mode='dark']) .ogcr-button--outlined:hover:not(:disabled),
:global([data-mode='dark']) .ogcr-button--text:hover:not(:disabled) {
background: var(--color-surface-800);
}
:global([data-mode='dark']) .ogcr-button--text {
color: var(--color-primary-200);
}
:global([data-mode='dark']) .ogcr-button:focus-visible {
box-shadow:
0 0 0 2px var(--color-surface-900),
0 0 0 4px var(--interaction-primary-default);
}
</style>
5 changes: 5 additions & 0 deletions src/lib/obp/appDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import { env } from '$env/dynamic/public';
export const APP_DIRECTORY_PATH = '/obp/v6.0.0/app-directory';

/** The app-directory names this app links to. */
export const API_EXPLORER_URL = 'public_obp_api_explorer_url';
export const API_MANAGER_URL = 'public_obp_api_manager_url';
export const MCP_URL = 'public_obp_mcp_url';
export const PORTAL_URL = 'public_obp_portal_url';

/** The Portal's guide to authenticating against OBP (OAuth2 / OpenID Connect). */
export const PORTAL_AUTHENTICATION_PATH = '/developers/oauth2-oidc';

/** name -> URL, for the entries that hold an http(s) URL. */
export type AppDirectory = Record<string, string>;
Expand Down
83 changes: 83 additions & 0 deletions src/lib/obp/groupEntitlements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
additionalEntitlements,
groupsOfSpace,
operationsOn,
otherRoles,
type ObpGroup
} from './groupEntitlements';

const group = (overrides: Partial<ObpGroup>): ObpGroup => ({
group_id: 'g',
group_name: 'G',
list_of_roles: [],
...overrides
});

describe('operationsOn', () => {
it('lists the granted operations in CRUD order', () => {
const roles = [
'CanDeleteDynamicEntityRecord_activity',
'CanGetDynamicEntityRecord_activity',
'CanCreateDynamicEntityRecord_activity',
'CanUpdateDynamicEntityRecord_activity'
];
expect(operationsOn('activity', roles)).toEqual(['C', 'R', 'U', 'D']);
});

it('gives read only access as R', () => {
expect(operationsOn('activity', ['CanGetDynamicEntityRecord_activity'])).toEqual(['R']);
});

it('does not match an entity whose name only starts the same', () => {
expect(operationsOn('activity', ['CanGetDynamicEntityRecord_activity_on_chain'])).toEqual([]);
});
});

describe('otherRoles', () => {
it('keeps roles that are not record roles of a listed entity', () => {
const g = group({
list_of_roles: [
'CanGetDynamicEntityRecord_activity',
'CanGetDynamicEntityRecord_retired_entity',
'CanGetBank'
]
});
expect(otherRoles(g, ['activity'])).toEqual([
'CanGetDynamicEntityRecord_retired_entity',
'CanGetBank'
]);
});
});

describe('additionalEntitlements', () => {
it('lists each additional role once, with the groups that grant it', () => {
const admins = group({ group_id: 'a', list_of_roles: ['CanGetBank', 'CanGetUser'] });
const readers = group({
group_id: 'r',
list_of_roles: ['CanGetDynamicEntityRecord_activity', 'CanGetBank']
});
const result = additionalEntitlements([admins, readers], ['activity']);
expect(result.map(({ role, groups }) => [role, groups.map((g) => g.group_id)])).toEqual([
['CanGetBank', ['a', 'r']],
['CanGetUser', ['a']]
]);
});
});

describe('groupsOfSpace', () => {
const groups = [
group({ group_id: '1', group_name: 'Verifiers', bank_id: 'ogcr' }),
group({ group_id: '2', group_name: 'Admins', bank_id: 'ogcr' }),
group({ group_id: '3', group_name: 'Other bank', bank_id: 'gh.29.uk' }),
group({ group_id: '4', group_name: 'System', bank_id: null })
];

it('keeps the bank’s groups, sorted by name', () => {
expect(groupsOfSpace(groups, 'ogcr').map((g) => g.group_id)).toEqual(['2', '1']);
});

it('keeps system level groups for SYS', () => {
expect(groupsOfSpace(groups, 'SYS').map((g) => g.group_id)).toEqual(['4']);
});
});
Loading
Loading