From 92a904b8a926ca16819e579b8eb135d681b951ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 04:04:31 +0200 Subject: [PATCH] feat(client): add accessible design system and API client foundation Add design tokens, base components (page header, section, status chip, empty state, loading, announcer), a11y composables (text scaling, announcements), high-contrast theme and a typed HTTP client with ProblemDetails error mapping. --- client/src/components/AppAnnouncer.vue | 7 ++ client/src/components/AppEmptyState.vue | 21 ++++++ client/src/components/AppLoading.vue | 24 +++++++ client/src/components/AppPageHeader.vue | 16 +++++ client/src/components/AppSection.vue | 16 +++++ client/src/components/AppStatusChip.spec.ts | 17 +++++ client/src/components/AppStatusChip.vue | 14 ++++ client/src/composables/useA11yPrefs.ts | 44 +++++++++++++ client/src/composables/useAnnounce.ts | 14 ++++ client/src/design/tokens.css | 27 ++++++++ client/src/design/tokens.ts | 30 +++++++++ client/src/plugins/vuetify.ts | 48 +++++++++++++- client/src/services/api/client.spec.ts | 45 +++++++++++++ client/src/services/api/client.ts | 72 +++++++++++++++++++++ client/src/services/api/errors.ts | 32 +++++++++ client/src/services/api/health.ts | 9 +++ client/src/services/api/index.ts | 3 + client/src/styles/a11y.css | 20 +++++- 18 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 client/src/components/AppAnnouncer.vue create mode 100644 client/src/components/AppEmptyState.vue create mode 100644 client/src/components/AppLoading.vue create mode 100644 client/src/components/AppPageHeader.vue create mode 100644 client/src/components/AppSection.vue create mode 100644 client/src/components/AppStatusChip.spec.ts create mode 100644 client/src/components/AppStatusChip.vue create mode 100644 client/src/composables/useA11yPrefs.ts create mode 100644 client/src/composables/useAnnounce.ts create mode 100644 client/src/design/tokens.css create mode 100644 client/src/design/tokens.ts create mode 100644 client/src/services/api/client.spec.ts create mode 100644 client/src/services/api/client.ts create mode 100644 client/src/services/api/errors.ts create mode 100644 client/src/services/api/health.ts create mode 100644 client/src/services/api/index.ts diff --git a/client/src/components/AppAnnouncer.vue b/client/src/components/AppAnnouncer.vue new file mode 100644 index 0000000..9b10303 --- /dev/null +++ b/client/src/components/AppAnnouncer.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/components/AppEmptyState.vue b/client/src/components/AppEmptyState.vue new file mode 100644 index 0000000..320e0f9 --- /dev/null +++ b/client/src/components/AppEmptyState.vue @@ -0,0 +1,21 @@ + + + diff --git a/client/src/components/AppLoading.vue b/client/src/components/AppLoading.vue new file mode 100644 index 0000000..f83cf81 --- /dev/null +++ b/client/src/components/AppLoading.vue @@ -0,0 +1,24 @@ + + + diff --git a/client/src/components/AppPageHeader.vue b/client/src/components/AppPageHeader.vue new file mode 100644 index 0000000..55f8fd1 --- /dev/null +++ b/client/src/components/AppPageHeader.vue @@ -0,0 +1,16 @@ + + + diff --git a/client/src/components/AppSection.vue b/client/src/components/AppSection.vue new file mode 100644 index 0000000..4ad7b3e --- /dev/null +++ b/client/src/components/AppSection.vue @@ -0,0 +1,16 @@ + + + diff --git a/client/src/components/AppStatusChip.spec.ts b/client/src/components/AppStatusChip.spec.ts new file mode 100644 index 0000000..bbab026 --- /dev/null +++ b/client/src/components/AppStatusChip.spec.ts @@ -0,0 +1,17 @@ +import { mount } from '@vue/test-utils' +import { createVuetify } from 'vuetify' +import { describe, expect, it } from 'vitest' +import AppStatusChip from './AppStatusChip.vue' + +const vuetify = createVuetify() + +describe('AppStatusChip', () => { + it('conveys status as text, not only as colour', () => { + const wrapper = mount(AppStatusChip, { + props: { label: 'Zaakceptowany', color: 'success' }, + global: { plugins: [vuetify] }, + }) + + expect(wrapper.text()).toContain('Zaakceptowany') + }) +}) diff --git a/client/src/components/AppStatusChip.vue b/client/src/components/AppStatusChip.vue new file mode 100644 index 0000000..c9e62c3 --- /dev/null +++ b/client/src/components/AppStatusChip.vue @@ -0,0 +1,14 @@ + + + diff --git a/client/src/composables/useA11yPrefs.ts b/client/src/composables/useA11yPrefs.ts new file mode 100644 index 0000000..e62412b --- /dev/null +++ b/client/src/composables/useA11yPrefs.ts @@ -0,0 +1,44 @@ +import { computed, watch } from 'vue' +import { useStorage } from '@vueuse/core' +import { tokens } from '../design/tokens' + +const { min, max, step, basePercent } = tokens.fontScale + +/** + * User-controlled text scaling. Persisted and applied to the root element so all + * rem-based sizing scales with it (WCAG 1.4.4). + */ +export function useA11yPrefs() { + const fontScale = useStorage('tarninahub-font-scale', 1) + + const clamped = computed(() => Math.min(max, Math.max(min, fontScale.value))) + + watch( + clamped, + (value) => { + document.documentElement.style.fontSize = `${value * basePercent}%` + }, + { immediate: true }, + ) + + function increase(): void { + fontScale.value = Math.min(max, Number((clamped.value + step).toFixed(2))) + } + + function decrease(): void { + fontScale.value = Math.max(min, Number((clamped.value - step).toFixed(2))) + } + + function reset(): void { + fontScale.value = 1 + } + + return { + fontScale: clamped, + canIncrease: computed(() => clamped.value < max), + canDecrease: computed(() => clamped.value > min), + increase, + decrease, + reset, + } +} diff --git a/client/src/composables/useAnnounce.ts b/client/src/composables/useAnnounce.ts new file mode 100644 index 0000000..7995c66 --- /dev/null +++ b/client/src/composables/useAnnounce.ts @@ -0,0 +1,14 @@ +import { ref } from 'vue' + +/** + * Global, single live region content. Consumed by `AppAnnouncer.vue`. + * Setting the same message twice is forced to re-announce by clearing first. + */ +export const announcement = ref('') + +export function announce(message: string): void { + announcement.value = '' + window.setTimeout(() => { + announcement.value = message + }, 50) +} diff --git a/client/src/design/tokens.css b/client/src/design/tokens.css new file mode 100644 index 0000000..c5ec2fe --- /dev/null +++ b/client/src/design/tokens.css @@ -0,0 +1,27 @@ +:root { + --th-space-xs: 0.25rem; + --th-space-sm: 0.5rem; + --th-space-md: 1rem; + --th-space-lg: 1.5rem; + --th-space-xl: 2rem; + --th-space-2xl: 3rem; + + --th-radius-sm: 6px; + --th-radius-md: 10px; + --th-radius-lg: 16px; + --th-radius-pill: 999px; + + --th-touch-target: 44px; +} + +/* Simple, accessible layout primitives (no dependency on utility framework). */ +.th-stack > * + * { + margin-block-start: var(--th-space-md); +} + +.th-cluster { + display: flex; + flex-wrap: wrap; + gap: var(--th-space-sm); + align-items: center; +} diff --git a/client/src/design/tokens.ts b/client/src/design/tokens.ts new file mode 100644 index 0000000..6a051e7 --- /dev/null +++ b/client/src/design/tokens.ts @@ -0,0 +1,30 @@ +/** + * Design tokens for TarninaHub (single source of truth in JS/TS). + * CSS custom properties live in `src/design/tokens.css` and must stay in sync. + */ +export const tokens = { + spacing: { + xs: '0.25rem', + sm: '0.5rem', + md: '1rem', + lg: '1.5rem', + xl: '2rem', + '2xl': '3rem', + }, + radius: { + sm: '6px', + md: '10px', + lg: '16px', + pill: '999px', + }, + /** Minimum interactive target size (WCAG 2.2 AA 24px; we aim for senior-friendly 44px). */ + touchTarget: '44px', + fontScale: { + min: 1, + max: 1.5, + step: 0.25, + basePercent: 112.5, + }, +} as const + +export type DesignTokens = typeof tokens diff --git a/client/src/plugins/vuetify.ts b/client/src/plugins/vuetify.ts index 5e203dc..f68e42f 100644 --- a/client/src/plugins/vuetify.ts +++ b/client/src/plugins/vuetify.ts @@ -83,18 +83,62 @@ export const dark: ThemeDefinition = { }, } +export const highContrast: ThemeDefinition = { + dark: true, + colors: { + background: '#000000', + 'on-background': '#FFFFFF', + surface: '#000000', + 'on-surface': '#FFFFFF', + 'surface-bright': '#000000', + 'on-surface-bright': '#FFFFFF', + 'surface-light': '#000000', + 'on-surface-light': '#FFFFFF', + 'surface-variant': '#FFFFFF', + 'on-surface-variant': '#000000', + + primary: '#FFFF00', + 'on-primary': '#000000', + secondary: '#00FFFF', + 'on-secondary': '#000000', + info: '#80D8FF', + 'on-info': '#000000', + success: '#69F0AE', + 'on-success': '#000000', + warning: '#FFD740', + 'on-warning': '#000000', + error: '#FF8A80', + 'on-error': '#000000', + + outline: '#FFFFFF', + focus: '#FFFF00', + }, + variables: { + 'border-color': '#FFFFFF', + 'border-opacity': 1, + 'high-emphasis-opacity': 1, + 'medium-emphasis-opacity': 1, + 'disabled-opacity': 0.6, + }, +} + export default createVuetify({ components, directives, locale: { locale: 'pl', fallback: 'en', messages: { pl, en } }, - theme: { defaultTheme: 'system', themes: { light, dark } }, + theme: { defaultTheme: 'system', themes: { light, dark, highContrast } }, defaults: { global: { ripple: false }, VBtn: { size: 'large', variant: 'flat', color: 'primary' }, VTextField: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VTextarea: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, VSelect: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, - VAutocomplete: { variant: 'outlined', density: 'default', color: 'primary', hideDetails: 'auto' }, + VAutocomplete: { + variant: 'outlined', + density: 'default', + color: 'primary', + hideDetails: 'auto', + }, VCheckbox: { color: 'primary', density: 'default' }, VRadioGroup: { color: 'primary' }, VSwitch: { color: 'primary', inset: true }, diff --git a/client/src/services/api/client.spec.ts b/client/src/services/api/client.spec.ts new file mode 100644 index 0000000..39ceb57 --- /dev/null +++ b/client/src/services/api/client.spec.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError, request, setAuthTokenProvider } from './index' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('api client', () => { + afterEach(() => { + vi.restoreAllMocks() + setAuthTokenProvider(() => null) + }) + + it('returns parsed JSON and attaches the bearer token', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: 'ok' })) + vi.stubGlobal('fetch', fetchMock) + setAuthTokenProvider(() => 'token-123') + + const result = await request<{ status: string }>('/api/health') + + expect(result.status).toBe('ok') + const init = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(init.headers).toMatchObject({ Authorization: 'Bearer token-123' }) + }) + + it('throws ApiError carrying ProblemDetails', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ title: 'Bad request', status: 400, errors: { field: ['nope'] } }, 400), + ), + ) + + const error = await request('/api/x').catch((value: unknown) => value) + + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).status).toBe(400) + expect((error as ApiError).validationMessages).toEqual(['nope']) + }) +}) diff --git a/client/src/services/api/client.ts b/client/src/services/api/client.ts new file mode 100644 index 0000000..d84376d --- /dev/null +++ b/client/src/services/api/client.ts @@ -0,0 +1,72 @@ +import { ApiError, type ProblemDetails } from './errors' + +const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '' + +let tokenProvider: () => string | null = () => null + +/** Wires the API client to the session store without creating a hard dependency. */ +export function setAuthTokenProvider(provider: () => string | null): void { + tokenProvider = provider +} + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + body?: unknown + headers?: Record + signal?: AbortSignal + /** Attach the bearer token when available (default: true). */ + auth?: boolean +} + +async function parseBody(response: Response): Promise { + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined + } + + const contentType = response.headers.get('content-type') ?? '' + + if (!contentType.includes('application/json')) { + return undefined + } + + try { + return await response.json() + } catch { + return undefined + } +} + +/** + * Minimal typed fetch wrapper: JSON in/out, bearer auth, RFC 7807 error mapping. + * Throws `ApiError` on non-2xx responses. + */ +export async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, headers = {}, signal, auth = true } = options + + const requestHeaders: Record = { Accept: 'application/json', ...headers } + const token = auth ? tokenProvider() : null + + if (token) { + requestHeaders.Authorization = `Bearer ${token}` + } + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json' + } + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }) + + const payload = await parseBody(response) + + if (!response.ok) { + const problem = (payload as ProblemDetails | undefined) ?? null + throw new ApiError(response.status, problem) + } + + return payload as T +} diff --git a/client/src/services/api/errors.ts b/client/src/services/api/errors.ts new file mode 100644 index 0000000..2f3452c --- /dev/null +++ b/client/src/services/api/errors.ts @@ -0,0 +1,32 @@ +export interface ProblemDetails { + type?: string + title?: string + status?: number + detail?: string + errors?: Record +} + +export class ApiError extends Error { + readonly status: number + readonly problem: ProblemDetails | null + + constructor(status: number, problem: ProblemDetails | null, message?: string) { + super(message ?? problem?.title ?? `Request failed with status ${status}`) + this.name = 'ApiError' + this.status = status + this.problem = problem + } + + /** Flattened validation messages (ASP.NET ProblemDetails `errors`). */ + get validationMessages(): string[] { + if (!this.problem?.errors) { + return [] + } + + return Object.values(this.problem.errors).flat() + } +} + +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError +} diff --git a/client/src/services/api/health.ts b/client/src/services/api/health.ts new file mode 100644 index 0000000..484b9cd --- /dev/null +++ b/client/src/services/api/health.ts @@ -0,0 +1,9 @@ +import { request } from './client' + +export interface HealthResponse { + status: string +} + +export function getHealth(signal?: AbortSignal): Promise { + return request('/api/health', { signal, auth: false }) +} diff --git a/client/src/services/api/index.ts b/client/src/services/api/index.ts new file mode 100644 index 0000000..293b399 --- /dev/null +++ b/client/src/services/api/index.ts @@ -0,0 +1,3 @@ +export { request, setAuthTokenProvider, type RequestOptions } from './client' +export { ApiError, isApiError, type ProblemDetails } from './errors' +export { getHealth, type HealthResponse } from './health' diff --git a/client/src/styles/a11y.css b/client/src/styles/a11y.css index 46dfd16..c0e5000 100644 --- a/client/src/styles/a11y.css +++ b/client/src/styles/a11y.css @@ -4,6 +4,9 @@ .v-theme--dark { --a11y-focus: #ffd54f; } +.v-theme--highContrast { + --a11y-focus: #ffff00; +} html { font-size: 112.5%; @@ -13,8 +16,21 @@ body { line-height: 1.6; } -:where(a, button, input, select, textarea, summary, [tabindex], .v-btn, .v-field, .v-list-item, - .v-tab, .v-chip--link, .v-selection-control__input):focus-visible { +:where( + a, + button, + input, + select, + textarea, + summary, + [tabindex], + .v-btn, + .v-field, + .v-list-item, + .v-tab, + .v-chip--link, + .v-selection-control__input +):focus-visible { outline: 3px solid var(--a11y-focus) !important; outline-offset: 2px; }