docs(08): add UI design contract
This commit is contained in:
354
.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
Normal file
354
.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
Normal file
@@ -0,0 +1,354 @@
|
||||
---
|
||||
phase: 8
|
||||
slug: oauth2-1-authorization-server
|
||||
status: draft
|
||||
shadcn_initialized: true
|
||||
preset: "shadcn-vue new-york; zinc base; CSS variables; Tailwind v4; Lucide"
|
||||
created: 2026-09-23
|
||||
---
|
||||
|
||||
# Phase 8 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for the existing Nuxt OAuth consent and connected-app surfaces. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
|
||||
## Scope and Non-Redesign Rule
|
||||
|
||||
Phase 8 changes the Go backend only. The existing `vue-fonoteka-app` remains byte-for-byte unchanged. This document therefore specifies the frontend states and payloads the backend must preserve; it does not authorize new components, restyling, copy changes, new routes, or changes to the Nuxt store/composable.
|
||||
|
||||
In scope:
|
||||
|
||||
- localized `/polacz?request=…` and `/en/connect?request=…` consent screen;
|
||||
- Settings → Integrations connected-app list and revoke interaction;
|
||||
- loading, invalid/expired, error, empty, populated, allow, deny, and revoke states;
|
||||
- the API response/status behavior that selects those existing states.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- redesigning either surface;
|
||||
- adding a loading skeleton, countdown, retry control, collection selector, or app logo;
|
||||
- displaying `offline_access`, raw redirect URIs, client IDs, token values, refresh tokens, or request handles;
|
||||
- changing the Nuxt app, MCP server, social-login account screen, or personal-token manager.
|
||||
|
||||
Sources of truth, in descending order: `08-CONTEXT.md` decisions D-08/D-12/D-16/D-20, the existing Nuxt files (`connect.vue`, `settings.vue`, `ConsentScopePicker.vue`, `ConnectedAppsManager.vue`, store/composable/types/locales), and the PHP consent/connected-app controllers.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | shadcn-vue, already initialized in the unchanged Nuxt repository |
|
||||
| Preset | `new-york`; `baseColor: zinc`; CSS variables; no prefix; square radius (`--radius: 0`) |
|
||||
| Component library | Reka UI primitives through local shadcn-vue components |
|
||||
| Icon library | Lucide; `Trash2` is the only phase-specific icon |
|
||||
| Fonts | IBM Plex Sans for app copy, IBM Plex Mono for redirect host, Instrument Serif only where the existing shared `EmptyState`/brand uses it |
|
||||
| Theme | forced dark only; no theme switcher or light palette |
|
||||
| Styling | Tailwind CSS v4 CSS-first tokens in `app/assets/css/main.css` |
|
||||
|
||||
The shadcn CLI binary is not installed in the Nuxt checkout; the checked-in `components.json` and vendored component source were inspected directly. Since D-08 locks the Nuxt client as unchanged, there is no preset confirmation/override decision to make.
|
||||
|
||||
### Existing Component Inventory
|
||||
|
||||
| Surface | Existing components | Contract |
|
||||
|---------|---------------------|----------|
|
||||
| Consent | public layout, `Button`, `ConsentScopePicker` | One centered decision card, no authenticated app chrome inside the page, no modal, all requested data scopes selected initially |
|
||||
| Connected apps | Settings tab shell, `Card`, shared `EmptyState`, `Button`, Reka `Dialog` | Sibling section after `TokenManager`; card list when populated; confirmation dialog before revoke |
|
||||
| Feedback | inline semantic text, disabled button states, `vue-sonner` toasts | Reads use inline states; mutations use existing generic failure toast and revoke-success toast; no new notification pattern |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (all multiples of 4):
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| xs | 4px | Tight inline separation and checkbox alignment |
|
||||
| sm | 8px | Button/list gaps and compact card padding |
|
||||
| md | 16px | Consent page horizontal padding, default element spacing |
|
||||
| lg | 24px | Consent card/dialog padding and section rhythm |
|
||||
| xl | 32px | Reserved layout gap; inherited app shell only |
|
||||
| 2xl | 48px | Contextual empty-state vertical padding |
|
||||
| 3xl | 64px | Reserved page-level separation; inherited app shell only |
|
||||
|
||||
Exceptions that are already implemented and must be preserved rather than normalized:
|
||||
|
||||
- interactive consent buttons, scope rows, settings tabs, and revoke icon button have a minimum 44px target;
|
||||
- the app shell uses 20px mobile and 28px desktop horizontal padding;
|
||||
- consent page uses 48px vertical padding and a 24px card rhythm;
|
||||
- the connected-app scope badges retain their existing compact Tailwind `gap-1.5`; it is an inherited component detail, not a new phase spacing token.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
The phase declares four app roles and exactly two design weights. Existing shared components remain unchanged even where their vendored utilities use `font-medium` or the display font.
|
||||
|
||||
| Role | Size | Weight | Line Height |
|
||||
|------|------|--------|-------------|
|
||||
| Meta | 12px | 400 | 1.5 |
|
||||
| Body / control | 14px | 400 | 1.5 |
|
||||
| Page/card heading | 16px | 600 | 1.2 |
|
||||
| Section heading | 20px | 600 | 1.2 |
|
||||
|
||||
Additional locked rendering details:
|
||||
|
||||
- `redirect_host` uses IBM Plex Mono at the body size so the destination domain remains visually distinct;
|
||||
- the untrusted application name is rendered as the existing quotation treatment (`text-lg font-medium`) with a disclaimer, never as trusted branding;
|
||||
- the shared connected-app empty state keeps its existing Instrument Serif title treatment;
|
||||
- headings use tight tracking; body, hint, date, and error copy use IBM Plex Sans.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
The implementation uses exact OKLCH semantic tokens, not hard-coded hex colors.
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | `--canvas: oklch(0.13 0.008 60)` and `--background: oklch(0.155 0.008 60)` | Public/app ground and primary page surfaces |
|
||||
| Secondary (30%) | `--card: oklch(0.172 0.009 60)`, `--muted: oklch(0.19 0.009 60)`, `--border: oklch(0.21 0.010 60)` | Consent card, connected-app card, scope rows, hover surfaces, borders |
|
||||
| Accent (10%) | `--primary: oklch(0.80 0.145 68)` | Allow button fill, keyboard focus ring, active Settings tab underline only |
|
||||
| Destructive | `--destructive: oklch(0.72 0.10 30)` | Empty-scope/error text and connected-app revoke affordance/confirmation only |
|
||||
|
||||
Accent is reserved for the consent **Allow** action, active tab indicator, and focus visibility. **Deny** remains outline-styled because it cancels an authorization request without deleting account data. **Revoke** alone uses destructive styling because it immediately kills the access token and refresh lineage.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
All copy is pre-existing and locked. The backend returns data only; it must not send display-ready prose for these surfaces.
|
||||
|
||||
### Required Surface Copy
|
||||
|
||||
| Element | English | Polish |
|
||||
|---------|---------|--------|
|
||||
| Consent title | Allow access | Zezwól na dostęp |
|
||||
| Consent lead | A chat application is asking for access to your Płytarium collection. | Aplikacja czatowa prosi o dostęp do Twojej kolekcji w Płytarium. |
|
||||
| Untrusted-name disclaimer | Name supplied by the application — not verified by Płytarium | Nazwa podana przez aplikację — nie jest weryfikowana przez Płytarium |
|
||||
| Primary CTA | Allow | Zezwól |
|
||||
| Secondary CTA | Deny | Odmów |
|
||||
| Empty scope | Select at least one permission. | Wybierz co najmniej jedno uprawnienie. |
|
||||
| Missing request | There is no valid access request. Return to the chat application and connect Płytarium again. | Brak ważnego żądania dostępu. Wróć do aplikacji czatowej i połącz Płytarium ponownie. |
|
||||
| Expired defensive state | This request expired or was already used. Return to the chat application and connect again. | To żądanie wygasło albo zostało już użyte. Wróć do aplikacji czatowej i połącz ponownie. |
|
||||
| Consent load error | Could not load the access request. Try again. | Nie udało się wczytać prośby o dostęp. Spróbuj ponownie. |
|
||||
| Connected-app heading | Connected applications | Połączone aplikacje |
|
||||
| Empty state | You have no connected chat applications yet. | Nie masz jeszcze połączonych aplikacji czatowych. |
|
||||
| Connected-app load error | Could not load connected applications. Try again. | Nie udało się wczytać połączonych aplikacji. Spróbuj ponownie. |
|
||||
| Revoke title | Revoke {name}? | Odwołać {name}? |
|
||||
| Revoke confirmation | This application will immediately lose access to the collection. This cannot be undone. | Ta aplikacja natychmiast straci dostęp do kolekcji. Tej operacji nie można cofnąć. |
|
||||
| Revoke success | Access revoked | Dostęp odwołany |
|
||||
|
||||
Scope labels and explanations remain the existing localized `Read`/`Write`/`AI` copy. `client_name`, collection names, email, dates, and counts are data substitutions and must be rendered through Vue interpolation, never as HTML.
|
||||
|
||||
---
|
||||
|
||||
## Consent Screen Contract
|
||||
|
||||
### Entry and Authentication
|
||||
|
||||
1. The server sends successful `/oauth/mcp/authorize` requests to `<app URL>/connect?request=<opaque>`; Nuxt localizes this to `/polacz` (PL) or `/en/connect` (EN).
|
||||
2. The query value is accepted only when it matches `^[A-Za-z0-9_-]{16,128}$`. Missing, repeated-first-invalid, malformed, or non-string values make `requestId` null and issue no consent API request.
|
||||
3. The page uses the existing UX auth middleware. A logged-out visitor goes to localized login with the validated consent path as the return target. The backend JWT group remains the real authorization boundary.
|
||||
4. The page sets `Referrer-Policy` via `<meta name="referrer" content="no-referrer">`; the opaque handle must not leak to the registered redirect host, logs, or analytics.
|
||||
|
||||
### Read Payload
|
||||
|
||||
`GET /_fonoteka/api/v1/oauth/request/{request_id}` must return:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"client_name": "string, control characters removed, maximum 120 characters",
|
||||
"redirect_host": "host only, never the full redirect URI",
|
||||
"scopes_requested": ["read", "write", "ai"],
|
||||
"collection_name": "server-resolved active collection name",
|
||||
"expires_at": "ISO 8601 timestamp"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Contract details:
|
||||
|
||||
- `scopes_requested` contains only the ordered, mintable intersection of `read`, `write`, and `ai`; `offline_access` is protocol state and is never displayed or submitted by this picker;
|
||||
- the UI initially selects every returned scope and offers only those returned scopes, in canonical read → write → ai order;
|
||||
- `collection_name` is display-only and carries no collection ID; consent pins collection IDs server-side using the active collection resolver;
|
||||
- `expires_at` remains part of the exact payload although the current screen does not render a countdown;
|
||||
- `client_name` is untrusted quotation text, HTML-escaped by Vue and paired with the explicit unverified-name disclaimer.
|
||||
|
||||
### State Matrix
|
||||
|
||||
| State | Trigger | Existing rendering | Backend obligation |
|
||||
|-------|---------|--------------------|--------------------|
|
||||
| Awaiting read | top-level awaited `useFetch` is unresolved | Nuxt suspense/layout owns the wait; no phase-specific skeleton or spinner | Respond within the existing client timeout; do not invent a partial payload |
|
||||
| Missing/invalid handle | query absent or fails the closed pattern | centered title + `connect.missing`; no card and no API call | None; malformed handles must never be echoed into a request |
|
||||
| Loaded | HTTP 200 with exact `data` object | centered max-width card showing app name, host, collection, scopes, acting email, and two actions | Return every required field with stable types |
|
||||
| Empty selected scopes | user clears the last offered scope | destructive inline `scopeRequired`; Allow disabled; Deny remains enabled | Server independently rejects an empty/no-longer-grantable intersection with exact 422 `{"error":"No grantable scopes"}` |
|
||||
| Missing/stale/used/expired/foreign | exact backend 404 `{"error":"Request not found"}` | current `useFetch` enters error status and shows `connect.loadError`; the separate `expired` branch is only a defensive success-with-null fallback | Preserve the parity 404; do not change it to 200/null merely to select different copy |
|
||||
| Other read failure | network, timeout, 5xx, or malformed payload | centered title + `connect.loadError` | No house envelope or secret-bearing diagnostic text |
|
||||
| Allow/deny pending | mutation begins | both actions disabled; no spinner or optimistic redirect | Single-use transition must be atomic; duplicate action cannot issue two outcomes |
|
||||
| Mutation failure | 404/422/network/5xx | generic existing failure toast; buttons re-enable and screen stays in place | Return exact status/body and never expose code, verifier, request handle, or redirect internals in error text |
|
||||
|
||||
### Scope Selection and Actions
|
||||
|
||||
- Each scope is a native checkbox inside a full-width label with a 44px minimum target, label, and explanatory hint.
|
||||
- Clearing every checkbox immediately shows the inline requirement and disables **Allow**.
|
||||
- **Allow** sends JSON `{ "request_id": "…", "scopes": [/* selected subset */] }` to `POST /oauth/consent`.
|
||||
- **Deny** sends JSON `{ "request_id": "…" }` to `POST /oauth/deny`; it does not require a selected scope.
|
||||
- Both actions are guarded by the component-local `acting` flag and the store-wide `isLoading` flag.
|
||||
- Successful actions return `{ "data": { "redirect_to": "…" } }`. The client performs `window.location.assign(redirect_to)`, not Vue-router navigation. The backend must construct this value exclusively from the exact registered redirect URI plus ordered RFC3986 parameters.
|
||||
- Allow success appends `code`, `iss`, then optional `state`. Deny success appends `error=access_denied`, `iss`, then optional `state`.
|
||||
- A missing/blank `redirect_to` leaves the user on the page; therefore every successful consent/deny response must include a non-empty value.
|
||||
|
||||
### Responsive and Layout Behavior
|
||||
|
||||
- Page container: centered, full width, max `32rem`, minimum 70vh, 16px horizontal and 48px vertical padding.
|
||||
- Consent card: square corners, border, card surface, subtle shadow, 24px padding and vertical rhythm.
|
||||
- Actions stack vertically on narrow viewports; at `sm` (640px) they become a right-aligned row with Deny first and Allow second.
|
||||
- Long app names remain text, not a logo. Redirect host uses monospace and must contain only the host. The card must not gain horizontal scrolling from backend data.
|
||||
|
||||
---
|
||||
|
||||
## Connected Applications Contract
|
||||
|
||||
### Placement and Read Payload
|
||||
|
||||
The existing Settings → Integrations tab renders `TokenManager` followed by `ConnectedAppsManager`. Phase 8 must not add a new settings tab or merge OAuth apps into the hand-minted token list.
|
||||
|
||||
`GET /_fonoteka/api/v1/oauth/connected-apps` must return:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 123,
|
||||
"name": "access-token name",
|
||||
"client_name": "string",
|
||||
"scopes": ["read", "write", "ai"],
|
||||
"collection_ids": [7],
|
||||
"collections": [{ "id": 7, "name": "Collection" }],
|
||||
"last_used_at": null,
|
||||
"last_used_ip": null,
|
||||
"expires_at": "ISO 8601 timestamp or null",
|
||||
"revoked_at": null,
|
||||
"created_at": "ISO 8601 timestamp"
|
||||
}
|
||||
],
|
||||
"manual_tokens_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
Contract details:
|
||||
|
||||
- include live, unrevoked OAuth access-token rows owned by the current user only, ordered newest first;
|
||||
- exclude manual tokens from `data`; expose only their live count in `manual_tokens_count` for localized plural copy;
|
||||
- each row is the existing positive allow-list from `serializeToken` plus sanitized `client_name`; the current component consumes `id`, `client_name`, `scopes`, `collections`, `last_used_at`, and `expires_at`, while parity retains the other listed serializer fields;
|
||||
- never serialize raw access/refresh tokens, OAuth client IDs, client secrets, hashes, request IDs, redirect URIs, other users, or fields outside that positive allow-list;
|
||||
- sanitize and truncate `client_name` exactly as on consent;
|
||||
- collection names are comma-joined by the existing client; return `[]`, never `null`, if no collection is present;
|
||||
- timestamps are ISO strings or null. The client uses locale `toLocaleDateString()` and renders null as localized “Never”; it does not display `created_at` today, but the type and parity payload retain it.
|
||||
|
||||
### State Matrix
|
||||
|
||||
| State | Existing rendering | Backend obligation |
|
||||
|-------|--------------------|--------------------|
|
||||
| Awaiting read | awaited settings data path; no phase-specific skeleton | Return one complete response; do not stream partial rows |
|
||||
| Read failure | bordered destructive inline paragraph with localized retry copy | Fail with an appropriate status; no partial list, secrets, or ownership clues |
|
||||
| Empty | shared centered `EmptyState`, 48px vertical padding, heading + body, no CTA | Return exact `data: []` and numeric `manual_tokens_count` |
|
||||
| Populated | bordered card with divided rows | Preserve newest-first order and stable positive allow-list fields |
|
||||
| Revoke dialog open | app name in title, irreversible-warning description, Cancel + destructive Revoke | The app name is untrusted text and must remain sanitized |
|
||||
| Revoking | destructive action disabled; dialog remains open | Revoke token and complete refresh lineage atomically |
|
||||
| Revoke success | dialog closes; “Access revoked” toast; connected-app and token reads refresh | Return 2xx with `{"data":{"revoked":true}}`; revoked app must disappear immediately from the next read |
|
||||
| Revoke failure | existing generic error toast; dialog remains open; action re-enables | Missing, foreign, or manual-token IDs all return the same 404 `{"error":"Token not found"}` |
|
||||
|
||||
### Row and Revoke Interaction
|
||||
|
||||
- Each row is at least 44px high with muted hover feedback.
|
||||
- `client_name` truncates visually in the row; its complete value remains available in the revoke dialog title and button accessible name.
|
||||
- Scope badges wrap and use localized labels; unknown scopes must not be emitted because the client has no translation contract for them.
|
||||
- Collection names, Last used, and Expires appear in one muted metadata line and may wrap naturally on narrow screens.
|
||||
- The icon-only `Trash2` button is 44×44px and has the localized accessible name `Revoke {client_name}?`.
|
||||
- The Reka dialog traps focus, supports Escape/close behavior, restores focus, uses an 80% black overlay, fits within viewport width minus 32px, and caps at `32rem`.
|
||||
- Dialog actions stack in reverse order on narrow screens and become a right-aligned row at `sm`.
|
||||
- Revocation is destructive and irreversible from the UI. Cancel never calls the API.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Contract
|
||||
|
||||
- Keep one `h1` on consent and the existing Settings page heading hierarchy (`h1` page, `h2` connected applications).
|
||||
- Native checkbox inputs remain associated through wrapping labels; every scope row has a 44px minimum target.
|
||||
- All keyboard-focusable controls retain the global 2px primary focus outline with 2px offset; disabled controls use native disabled semantics and cannot receive pointer actions.
|
||||
- The settings tab list retains its `aria-label`; revoke remains a button with a dynamic localized `aria-label` rather than an unlabeled icon.
|
||||
- Dialog title and description remain Reka primitives so the modal has an accessible name/description and managed focus.
|
||||
- Error information is conveyed by localized text in addition to destructive color. Do not introduce color-only scope or revocation states.
|
||||
- Respect the existing global reduced-motion rule. No new animation is part of this phase.
|
||||
- Untrusted client/collection strings are rendered with Vue interpolation only. Never provide or request `v-html` content.
|
||||
|
||||
---
|
||||
|
||||
## Security-Sensitive Interaction Rules
|
||||
|
||||
1. Never show or log the request handle, authorization code, PKCE challenge/verifier, client secret, access token, or refresh token.
|
||||
2. Validate the registered redirect URI before creating the consent request. The browser receives only a host for display and a server-built `redirect_to` after a valid terminal action.
|
||||
3. A pending handle is single-use, expires after the configured TTL, and may be bound/consumed by only the correct authenticated user.
|
||||
4. Submitted scopes are untrusted. Grant only `submitted ∩ originally requested ∩ client ceiling ∩ MINTABLE_SCOPES`; derive collection IDs server-side.
|
||||
5. Client and collection names are untrusted plain text. Strip control characters server-side, cap client names at 120 characters, and rely on Vue escaping.
|
||||
6. A foreign/missing consent request and a foreign/missing/manual connected-app ID use indistinguishable 404 responses.
|
||||
7. Revoke kills the access token and the entire refresh lineage in one committed transaction before the UI reports success.
|
||||
8. Do not add the MCP resource server's RFC 9728 challenge or metadata to these backend UI endpoints; D-12 keeps that concern in `fonoteka-mcp`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
The planner/executor must prove backend compatibility without editing Nuxt source:
|
||||
|
||||
- invalid query handle causes no `oauth/request` request;
|
||||
- logged-out consent entry returns through the closed safe-return-path allow-list after login;
|
||||
- 200 request payload renders app name, host, active collection, all returned scopes selected, and acting email;
|
||||
- zero selected scopes disables Allow while Deny still works;
|
||||
- stale/used/expired/foreign request produces exact 404 and existing error state;
|
||||
- Allow and Deny each produce one full-page redirect using the server response;
|
||||
- connected-app reads cover error, empty, manual-count plural, and populated states;
|
||||
- revoke covers cancel, pending disabled state, success refresh/toast, failure retention, and identical 404 for foreign/manual IDs;
|
||||
- keyboard traversal, focus visibility, dialog focus management, 44px targets, and mobile button stacking remain unchanged;
|
||||
- English and Polish copy keys resolve; no raw i18n key appears;
|
||||
- the Nuxt repository has no Phase 8 diff after backend implementation.
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn-vue official/local | Existing `Button`, `Card`, `Dialog` only | checked-in source and `components.json` inspected — no new block — 2026-09-23 |
|
||||
| Third-party registries | none (`registries: {}`) | no third-party registry declared — 2026-09-23 |
|
||||
|
||||
No registry installation, network fetch, or component generation is authorized by this phase.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
| Source | Decisions Used |
|
||||
|--------|----------------|
|
||||
| `08-CONTEXT.md` | Phase boundary; unchanged Nuxt; app-owned consent/connected-app shapes; exact redirects; security and parity rules |
|
||||
| `08-RESEARCH.md` | Exact API matrix; scope/tenant rules; stale/foreign behavior; lifecycle and verification risks |
|
||||
| `REQUIREMENTS.md` | AUTH-05, AUTH-06, AUTH-07 and unchanged-client acceptance |
|
||||
| Nuxt source | All layout, state, typography, color, spacing, copy, accessibility, responsive, and store-refresh behavior |
|
||||
| PHP source | Exact payloads, status codes, sanitization, ownership, and atomic revoke semantics |
|
||||
| User input | No new visual choices; prior locked decision to keep the Nuxt app unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user