diff --git a/backend/src/middleware/auditLog.js b/backend/src/middleware/auditLog.js index 05c8313..b01d655 100644 --- a/backend/src/middleware/auditLog.js +++ b/backend/src/middleware/auditLog.js @@ -5,16 +5,22 @@ const AUDIT_LOG_PATH = process.env.AUDIT_LOG_PATH || path.join(__dirname, '../.. /** * Append a single audit entry to the append-only NDJSON log file. - * This is the only write path — there is no update or delete. + * Uses async write to avoid blocking the request/response cycle. */ function writeAuditEntry(entry) { const line = JSON.stringify(entry) + '\n'; - // appendFileSync keeps writes atomic enough for a single-process server - fs.appendFileSync(AUDIT_LOG_PATH, line, 'utf8'); + // Non-blocking append — fire and forget to avoid blocking request handlers + fs.appendFile(AUDIT_LOG_PATH, line, 'utf8', (err) => { + if (err) { + // Log to stderr but don't crash the server + console.error(`[AuditLog] Failed to write entry: ${err.message}`); + } + }); } /** * Build and persist an audit log entry. + * Non-blocking — does not wait for file write to complete. * * @param {object} opts * @param {string} opts.actor - Stellar public key of the acting wallet diff --git a/backend/src/routes/vaccination.js b/backend/src/routes/vaccination.js index 4a92851..9fc4dba 100644 --- a/backend/src/routes/vaccination.js +++ b/backend/src/routes/vaccination.js @@ -3,7 +3,7 @@ const StellarSdk = require('@stellar/stellar-sdk'); const authMiddleware = require('../middleware/auth'); const issuerMiddleware = require('../middleware/issuer'); const { validateStellarPublicKey } = require('../middleware/wallet'); -const { invokeContract, simulateContract, mintVaccination, sendRpcTimeout, SorobanTimeoutError } = require('../stellar/soroban'); +const { invokeContract, simulateContract, mintVaccination, sendRpcTimeout, SorobanTimeoutError, checkDuplicateRecord } = require('../stellar/soroban'); const { resolveContractErrorMessage, mapContractError } = require('../stellar/contractErrors'); const { audit } = require('../middleware/auditLog'); const validate = require('../middleware/validate'); @@ -63,6 +63,8 @@ const router = express.Router(); * description: Unauthorized * 403: * description: Forbidden - issuer role required + * 409: + * description: Duplicate record * 500: * description: Contract invocation failed * content: @@ -85,6 +87,27 @@ router.post( return res.status(403).json({ error: 'Patient has not provided consent. They must consent before a record can be issued.' }); } + // Check for duplicate record before invoking contract + try { + const existingTokenId = await checkDuplicateRecord(patient_address, vaccine_name, date_administered); + if (existingTokenId) { + audit({ + actor: req.user.publicKey, + action: 'vaccination.issue', + target: patient_address, + result: 'failure', + meta: { error: 'Duplicate record', existing_token_id: existingTokenId }, + }); + return res.status(409).json({ + error: 'Duplicate record', + existing_token_id: existingTokenId, + }); + } + } catch (err) { + // If duplicate check fails, continue to contract invocation + // The contract will also check for duplicates + } + try { const result = await mintVaccination( patient_address, diff --git a/backend/src/stellar/soroban.js b/backend/src/stellar/soroban.js index ea0dec8..215b177 100644 --- a/backend/src/stellar/soroban.js +++ b/backend/src/stellar/soroban.js @@ -279,6 +279,34 @@ async function simulateContract(method, args) { return sim.result?.retval; } +/** + * Check if a patient already has a vaccination record with the same vaccine and date. + * Returns the token_id if found, null otherwise. + * @param {string} wallet - Patient wallet address + * @param {string} vaccineName - Vaccine name to check + * @param {string} dateAdministered - Date to check + */ +async function checkDuplicateRecord(wallet, vaccineName, dateAdministered) { + try { + const result = await verifyVaccination(wallet); + if (!result.vaccinated || !result.records || result.records.length === 0) { + return null; + } + + // Find matching record by vaccine name and date + const duplicate = result.records.find( + (record) => + record.vaccine_name === vaccineName && + record.date_administered === dateAdministered + ); + + return duplicate ? duplicate.token_id : null; + } catch (error) { + // If verification fails, let the contract handle it + return null; + } +} + /** * Send a 503 RPC timeout response. Use in route catch blocks when err is SorobanTimeoutError. */ @@ -292,6 +320,7 @@ module.exports = { simulateContract, mintVaccination, verifyVaccination, + checkDuplicateRecord, addIssuer, revokeIssuer, sendRpcTimeout, diff --git a/frontend/src/index.css b/frontend/src/index.css index 465ad8f..040bc5b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,77 @@ :root { + /* Primary palette */ + --color-primary-50: #f0f9ff; + --color-primary-100: #e0f2fe; + --color-primary-200: #bae6fd; + --color-primary-300: #7dd3fc; + --color-primary-400: #38bdf8; + --color-primary-500: #0ea5e9; + --color-primary-600: #0284c7; + --color-primary-700: #0369a1; + --color-primary-800: #075985; + --color-primary-900: #0c3d66; + + /* Secondary palette */ + --color-secondary-50: #f5f3ff; + --color-secondary-100: #ede9fe; + --color-secondary-200: #ddd6fe; + --color-secondary-300: #c4b5fd; + --color-secondary-400: #a78bfa; + --color-secondary-500: #8b5cf6; + --color-secondary-600: #7c3aed; + --color-secondary-700: #6d28d9; + --color-secondary-800: #5b21b6; + --color-secondary-900: #4c1d95; + + /* Semantic colors - Success */ + --color-success-50: #f0fdf4; + --color-success-100: #dcfce7; + --color-success-200: #bbf7d0; + --color-success-300: #86efac; + --color-success-400: #4ade80; + --color-success-500: #22c55e; + --color-success-600: #16a34a; + --color-success-700: #15803d; + --color-success-800: #166534; + --color-success-900: #145231; + + /* Semantic colors - Error */ + --color-error-50: #fef2f2; + --color-error-100: #fee2e2; + --color-error-200: #fecaca; + --color-error-300: #fca5a5; + --color-error-400: #f87171; + --color-error-500: #ef4444; + --color-error-600: #dc2626; + --color-error-700: #b91c1c; + --color-error-800: #991b1b; + --color-error-900: #7f1d1d; + + /* Semantic colors - Warning */ + --color-warning-50: #fffbeb; + --color-warning-100: #fef3c7; + --color-warning-200: #fde68a; + --color-warning-300: #fcd34d; + --color-warning-400: #fbbf24; + --color-warning-500: #f59e0b; + --color-warning-600: #d97706; + --color-warning-700: #b45309; + --color-warning-800: #92400e; + --color-warning-900: #78350f; + + /* Neutral palette */ + --color-neutral-50: #f9fafb; + --color-neutral-100: #f3f4f6; + --color-neutral-200: #e5e7eb; + --color-neutral-300: #d1d5db; + --color-neutral-400: #9ca3af; + --color-neutral-500: #6b7280; + --color-neutral-600: #4b5563; + --color-neutral-700: #374151; + --color-neutral-800: #1f2937; + --color-neutral-900: #111827; + + /* Semantic aliases for light mode */ --bg: #ffffff; --text: #0f172a; --text-muted: #64748b; @@ -15,9 +88,75 @@ --role-issuer-text: #16a34a; --focus-ring: #0284c7; --focus-ring-offset: #ffffff; + + /* Typography */ + --font-family: system-ui, -apple-system, sans-serif; + + /* Heading H1 */ + --font-h1-size: 2.25rem; + --font-h1-weight: 700; + --font-h1-line-height: 1.2; + + /* Heading H2 */ + --font-h2-size: 1.875rem; + --font-h2-weight: 700; + --font-h2-line-height: 1.25; + + /* Heading H3 */ + --font-h3-size: 1.5rem; + --font-h3-weight: 600; + --font-h3-line-height: 1.35; + + /* Heading H4 */ + --font-h4-size: 1.25rem; + --font-h4-weight: 600; + --font-h4-line-height: 1.4; + + /* Body text */ + --font-body-size: 1rem; + --font-body-weight: 400; + --font-body-line-height: 1.5; + + /* Body small */ + --font-body-sm-size: 0.875rem; + --font-body-sm-weight: 400; + --font-body-sm-line-height: 1.5; + + /* Caption */ + --font-caption-size: 0.75rem; + --font-caption-weight: 400; + --font-caption-line-height: 1.5; + + /* Label */ + --font-label-size: 0.875rem; + --font-label-weight: 500; + --font-label-line-height: 1.5; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + --spacing-3xl: 4rem; + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .dark { + /* Semantic aliases for dark mode */ --bg: #0f172a; --text: #e2e8f0; --text-muted: #94a3b8; @@ -36,8 +175,131 @@ --focus-ring-offset: #0f172a; } +/* Typography utility classes */ +.h1 { + font-size: var(--font-h1-size); + font-weight: var(--font-h1-weight); + line-height: var(--font-h1-line-height); +} + +.h2 { + font-size: var(--font-h2-size); + font-weight: var(--font-h2-weight); + line-height: var(--font-h2-line-height); +} + +.h3 { + font-size: var(--font-h3-size); + font-weight: var(--font-h3-weight); + line-height: var(--font-h3-line-height); +} + +.h4 { + font-size: var(--font-h4-size); + font-weight: var(--font-h4-weight); + line-height: var(--font-h4-line-height); +} + +.body { + font-size: var(--font-body-size); + font-weight: var(--font-body-weight); + line-height: var(--font-body-line-height); +} + +.body-sm { + font-size: var(--font-body-sm-size); + font-weight: var(--font-body-sm-weight); + line-height: var(--font-body-sm-line-height); +} + +.caption { + font-size: var(--font-caption-size); + font-weight: var(--font-caption-weight); + line-height: var(--font-caption-line-height); +} + +.label { + font-size: var(--font-label-size); + font-weight: var(--font-label-weight); + line-height: var(--font-label-line-height); +} + +/* Semantic heading elements */ +h1 { + font-size: var(--font-h1-size); + font-weight: var(--font-h1-weight); + line-height: var(--font-h1-line-height); + margin-bottom: var(--spacing-lg); +} + +h2 { + font-size: var(--font-h2-size); + font-weight: var(--font-h2-weight); + line-height: var(--font-h2-line-height); + margin-bottom: var(--spacing-md); +} + +h3 { + font-size: var(--font-h3-size); + font-weight: var(--font-h3-weight); + line-height: var(--font-h3-line-height); + margin-bottom: var(--spacing-md); +} + +h4 { + font-size: var(--font-h4-size); + font-weight: var(--font-h4-weight); + line-height: var(--font-h4-line-height); + margin-bottom: var(--spacing-sm); +} + +p { + font-size: var(--font-body-size); + font-weight: var(--font-body-weight); + line-height: var(--font-body-line-height); + margin-bottom: var(--spacing-md); +} + +small { + font-size: var(--font-body-sm-size); + font-weight: var(--font-body-sm-weight); + line-height: var(--font-body-sm-line-height); +} + +figcaption { + font-size: var(--font-caption-size); + font-weight: var(--font-caption-weight); + line-height: var(--font-caption-line-height); + color: var(--text-muted); +} + +label { + font-size: var(--font-label-size); + font-weight: var(--font-label-weight); + line-height: var(--font-label-line-height); +} + +/* Responsive typography adjustments for mobile */ +@media (max-width: 768px) { + h1 { + font-size: calc(var(--font-h1-size) * 0.875); + } + + h2 { + font-size: calc(var(--font-h2-size) * 0.875); + } + + h3 { + font-size: calc(var(--font-h3-size) * 0.875); + } + + h4 { + font-size: calc(var(--font-h4-size) * 0.875); + } +} + * { box-sizing: border-box; margin: 0; padding: 0; } -body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; } +body { font-family: var(--font-family); background: var(--bg); color: var(--text); min-height: 100vh; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } button { cursor: pointer; } diff --git a/frontend/src/styles/designTokens.js b/frontend/src/styles/designTokens.js new file mode 100644 index 0000000..8048a7c --- /dev/null +++ b/frontend/src/styles/designTokens.js @@ -0,0 +1,245 @@ +/** + * Design Tokens - Color System + * Defines a consistent color palette for light and dark modes + * Token naming convention: --color-{category}-{shade} + */ + +export const colorTokens = { + light: { + // Primary palette + 'color-primary-50': '#f0f9ff', + 'color-primary-100': '#e0f2fe', + 'color-primary-200': '#bae6fd', + 'color-primary-300': '#7dd3fc', + 'color-primary-400': '#38bdf8', + 'color-primary-500': '#0ea5e9', + 'color-primary-600': '#0284c7', + 'color-primary-700': '#0369a1', + 'color-primary-800': '#075985', + 'color-primary-900': '#0c3d66', + + // Secondary palette + 'color-secondary-50': '#f5f3ff', + 'color-secondary-100': '#ede9fe', + 'color-secondary-200': '#ddd6fe', + 'color-secondary-300': '#c4b5fd', + 'color-secondary-400': '#a78bfa', + 'color-secondary-500': '#8b5cf6', + 'color-secondary-600': '#7c3aed', + 'color-secondary-700': '#6d28d9', + 'color-secondary-800': '#5b21b6', + 'color-secondary-900': '#4c1d95', + + // Semantic colors + 'color-success-50': '#f0fdf4', + 'color-success-100': '#dcfce7', + 'color-success-200': '#bbf7d0', + 'color-success-300': '#86efac', + 'color-success-400': '#4ade80', + 'color-success-500': '#22c55e', + 'color-success-600': '#16a34a', + 'color-success-700': '#15803d', + 'color-success-800': '#166534', + 'color-success-900': '#145231', + + 'color-error-50': '#fef2f2', + 'color-error-100': '#fee2e2', + 'color-error-200': '#fecaca', + 'color-error-300': '#fca5a5', + 'color-error-400': '#f87171', + 'color-error-500': '#ef4444', + 'color-error-600': '#dc2626', + 'color-error-700': '#b91c1c', + 'color-error-800': '#991b1b', + 'color-error-900': '#7f1d1d', + + 'color-warning-50': '#fffbeb', + 'color-warning-100': '#fef3c7', + 'color-warning-200': '#fde68a', + 'color-warning-300': '#fcd34d', + 'color-warning-400': '#fbbf24', + 'color-warning-500': '#f59e0b', + 'color-warning-600': '#d97706', + 'color-warning-700': '#b45309', + 'color-warning-800': '#92400e', + 'color-warning-900': '#78350f', + + // Neutral palette + 'color-neutral-50': '#f9fafb', + 'color-neutral-100': '#f3f4f6', + 'color-neutral-200': '#e5e7eb', + 'color-neutral-300': '#d1d5db', + 'color-neutral-400': '#9ca3af', + 'color-neutral-500': '#6b7280', + 'color-neutral-600': '#4b5563', + 'color-neutral-700': '#374151', + 'color-neutral-800': '#1f2937', + 'color-neutral-900': '#111827', + + // Semantic aliases + 'color-bg': '#ffffff', + 'color-text': '#0f172a', + 'color-text-muted': '#64748b', + 'color-border': '#cbd5e1', + 'color-focus-ring': '#0284c7', + 'color-focus-ring-offset': '#ffffff', + }, + + dark: { + // Primary palette + 'color-primary-50': '#f0f9ff', + 'color-primary-100': '#e0f2fe', + 'color-primary-200': '#bae6fd', + 'color-primary-300': '#7dd3fc', + 'color-primary-400': '#38bdf8', + 'color-primary-500': '#0ea5e9', + 'color-primary-600': '#0284c7', + 'color-primary-700': '#0369a1', + 'color-primary-800': '#075985', + 'color-primary-900': '#0c3d66', + + // Secondary palette + 'color-secondary-50': '#f5f3ff', + 'color-secondary-100': '#ede9fe', + 'color-secondary-200': '#ddd6fe', + 'color-secondary-300': '#c4b5fd', + 'color-secondary-400': '#a78bfa', + 'color-secondary-500': '#8b5cf6', + 'color-secondary-600': '#7c3aed', + 'color-secondary-700': '#6d28d9', + 'color-secondary-800': '#5b21b6', + 'color-secondary-900': '#4c1d95', + + // Semantic colors + 'color-success-50': '#f0fdf4', + 'color-success-100': '#dcfce7', + 'color-success-200': '#bbf7d0', + 'color-success-300': '#86efac', + 'color-success-400': '#4ade80', + 'color-success-500': '#22c55e', + 'color-success-600': '#16a34a', + 'color-success-700': '#15803d', + 'color-success-800': '#166534', + 'color-success-900': '#145231', + + 'color-error-50': '#fef2f2', + 'color-error-100': '#fee2e2', + 'color-error-200': '#fecaca', + 'color-error-300': '#fca5a5', + 'color-error-400': '#f87171', + 'color-error-500': '#ef4444', + 'color-error-600': '#dc2626', + 'color-error-700': '#b91c1c', + 'color-error-800': '#991b1b', + 'color-error-900': '#7f1d1d', + + 'color-warning-50': '#fffbeb', + 'color-warning-100': '#fef3c7', + 'color-warning-200': '#fde68a', + 'color-warning-300': '#fcd34d', + 'color-warning-400': '#fbbf24', + 'color-warning-500': '#f59e0b', + 'color-warning-600': '#d97706', + 'color-warning-700': '#b45309', + 'color-warning-800': '#92400e', + 'color-warning-900': '#78350f', + + // Neutral palette + 'color-neutral-50': '#f9fafb', + 'color-neutral-100': '#f3f4f6', + 'color-neutral-200': '#e5e7eb', + 'color-neutral-300': '#d1d5db', + 'color-neutral-400': '#9ca3af', + 'color-neutral-500': '#6b7280', + 'color-neutral-600': '#4b5563', + 'color-neutral-700': '#374151', + 'color-neutral-800': '#1f2937', + 'color-neutral-900': '#111827', + + // Semantic aliases + 'color-bg': '#0f172a', + 'color-text': '#e2e8f0', + 'color-text-muted': '#94a3b8', + 'color-border': '#334155', + 'color-focus-ring': '#38bdf8', + 'color-focus-ring-offset': '#0f172a', + }, +}; + +/** + * Typography tokens + */ +export const typographyTokens = { + // Heading levels + 'font-h1-size': '2.25rem', + 'font-h1-weight': '700', + 'font-h1-line-height': '1.2', + + 'font-h2-size': '1.875rem', + 'font-h2-weight': '700', + 'font-h2-line-height': '1.25', + + 'font-h3-size': '1.5rem', + 'font-h3-weight': '600', + 'font-h3-line-height': '1.35', + + 'font-h4-size': '1.25rem', + 'font-h4-weight': '600', + 'font-h4-line-height': '1.4', + + // Body text + 'font-body-size': '1rem', + 'font-body-weight': '400', + 'font-body-line-height': '1.5', + + 'font-body-sm-size': '0.875rem', + 'font-body-sm-weight': '400', + 'font-body-sm-line-height': '1.5', + + // Caption + 'font-caption-size': '0.75rem', + 'font-caption-weight': '400', + 'font-caption-line-height': '1.5', + + // Label + 'font-label-size': '0.875rem', + 'font-label-weight': '500', + 'font-label-line-height': '1.5', + + // Font family + 'font-family': 'system-ui, -apple-system, sans-serif', +}; + +/** + * Spacing tokens + */ +export const spacingTokens = { + 'spacing-xs': '0.25rem', + 'spacing-sm': '0.5rem', + 'spacing-md': '1rem', + 'spacing-lg': '1.5rem', + 'spacing-xl': '2rem', + 'spacing-2xl': '3rem', + 'spacing-3xl': '4rem', +}; + +/** + * Border radius tokens + */ +export const borderRadiusTokens = { + 'radius-sm': '0.25rem', + 'radius-md': '0.375rem', + 'radius-lg': '0.5rem', + 'radius-xl': '0.75rem', + 'radius-full': '9999px', +}; + +/** + * Shadow tokens + */ +export const shadowTokens = { + 'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + 'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + 'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)', + 'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1)', +}; diff --git a/frontend/src/styles/typography.js b/frontend/src/styles/typography.js new file mode 100644 index 0000000..6b6b827 --- /dev/null +++ b/frontend/src/styles/typography.js @@ -0,0 +1,138 @@ +/** + * Typography utilities and mixins + * Provides consistent typography scale across the application + */ + +export const typographyClasses = ` + /* Heading H1 - Page titles */ + h1, .h1 { + font-size: var(--font-h1-size); + font-weight: var(--font-h1-weight); + line-height: var(--font-h1-line-height); + margin-bottom: var(--spacing-lg); + } + + /* Heading H2 - Section titles */ + h2, .h2 { + font-size: var(--font-h2-size); + font-weight: var(--font-h2-weight); + line-height: var(--font-h2-line-height); + margin-bottom: var(--spacing-md); + } + + /* Heading H3 - Subsection titles */ + h3, .h3 { + font-size: var(--font-h3-size); + font-weight: var(--font-h3-weight); + line-height: var(--font-h3-line-height); + margin-bottom: var(--spacing-md); + } + + /* Heading H4 - Minor headings */ + h4, .h4 { + font-size: var(--font-h4-size); + font-weight: var(--font-h4-weight); + line-height: var(--font-h4-line-height); + margin-bottom: var(--spacing-sm); + } + + /* Body text - Default paragraph text */ + p, .body { + font-size: var(--font-body-size); + font-weight: var(--font-body-weight); + line-height: var(--font-body-line-height); + margin-bottom: var(--spacing-md); + } + + /* Body small - Secondary text, descriptions */ + .body-sm, small { + font-size: var(--font-body-sm-size); + font-weight: var(--font-body-sm-weight); + line-height: var(--font-body-sm-line-height); + } + + /* Caption - Metadata, timestamps, helper text */ + .caption, figcaption { + font-size: var(--font-caption-size); + font-weight: var(--font-caption-weight); + line-height: var(--font-caption-line-height); + color: var(--text-muted); + } + + /* Label - Form labels, badges */ + label, .label { + font-size: var(--font-label-size); + font-weight: var(--font-label-weight); + line-height: var(--font-label-line-height); + } + + /* Responsive typography adjustments */ + @media (max-width: 768px) { + h1, .h1 { + font-size: calc(var(--font-h1-size) * 0.875); + } + + h2, .h2 { + font-size: calc(var(--font-h2-size) * 0.875); + } + + h3, .h3 { + font-size: calc(var(--font-h3-size) * 0.875); + } + + h4, .h4 { + font-size: calc(var(--font-h4-size) * 0.875); + } + } +`; + +/** + * Typography scale helper for inline styles + * Usage: getTypographyStyle('h1') returns CSS properties object + */ +export function getTypographyStyle(level) { + const styles = { + h1: { + fontSize: 'var(--font-h1-size)', + fontWeight: 'var(--font-h1-weight)', + lineHeight: 'var(--font-h1-line-height)', + }, + h2: { + fontSize: 'var(--font-h2-size)', + fontWeight: 'var(--font-h2-weight)', + lineHeight: 'var(--font-h2-line-height)', + }, + h3: { + fontSize: 'var(--font-h3-size)', + fontWeight: 'var(--font-h3-weight)', + lineHeight: 'var(--font-h3-line-height)', + }, + h4: { + fontSize: 'var(--font-h4-size)', + fontWeight: 'var(--font-h4-weight)', + lineHeight: 'var(--font-h4-line-height)', + }, + body: { + fontSize: 'var(--font-body-size)', + fontWeight: 'var(--font-body-weight)', + lineHeight: 'var(--font-body-line-height)', + }, + 'body-sm': { + fontSize: 'var(--font-body-sm-size)', + fontWeight: 'var(--font-body-sm-weight)', + lineHeight: 'var(--font-body-sm-line-height)', + }, + caption: { + fontSize: 'var(--font-caption-size)', + fontWeight: 'var(--font-caption-weight)', + lineHeight: 'var(--font-caption-line-height)', + }, + label: { + fontSize: 'var(--font-label-size)', + fontWeight: 'var(--font-label-weight)', + lineHeight: 'var(--font-label-line-height)', + }, + }; + + return styles[level] || styles.body; +}