From 3e46a9af260ec91af021d79866277f54fc1b1e52 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:10:56 +0000 Subject: [PATCH 1/6] chore(frontend): add a typography foundation (line-height, heading scale, control font) The body had no line-height, so text used the browser's ~1.2 default. Form controls did not inherit the page font, so .form-group inputs rendered at 13.33px system text next to 14px buttons. Headings had no base scale: every heading without a contextual rule got the browser's em-based default. - body: line-height var(--cudly-lh-normal) (1.5). - button, input, select, textarea: font: inherit, size pinned to --cudly-fs-sm (the button size) and line-height --cudly-lh-tight, so control heights stay where they were. Element selectors keep the existing `select`/`button`/class rules in charge. - :where(h1..h6): a 5-step scale on the --cudly-fs-* tokens (2xl, xl, lg, md, sm), tight line-height, bold weight. Zero specificity, so the ~20 contextual heading rules still win. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/base.css | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 74c206fbd..9dc47417a 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -97,10 +97,41 @@ html { body { font-family: var(--cudly-font-base); + line-height: var(--cudly-lh-normal); background: var(--cudly-bg); color: var(--cudly-text); } +/* Form controls do not inherit the page font by default (inputs fall back + * to 13.33px system text next to 14px buttons). Inherit family and weight, + * then pin the size to the button size and keep the line-height tight so + * control heights stay as they were. Element selectors, so the existing + * `select`, `button` and class rules later in the cascade still win. */ +button, +input, +select, +textarea { + font: inherit; + font-size: var(--cudly-fs-sm); + line-height: var(--cudly-lh-tight); +} + +/* Base heading scale on the type tokens, close to the browser defaults it + * replaces (h1 32px -> 28px, h2 24px -> 22px, h5/h6 grow to 14px). Weight + * stays the browser's bold so headings that only set a size keep their + * look. :where() keeps it at zero specificity so every contextual heading + * rule (.card h3, .page-hero h2, ...) still overrides it. */ +:where(h1, h2, h3, h4, h5, h6) { + line-height: var(--cudly-lh-tight); + font-weight: var(--cudly-fw-bold); +} + +:where(h1) { font-size: var(--cudly-fs-2xl); } +:where(h2) { font-size: var(--cudly-fs-xl); } +:where(h3) { font-size: var(--cudly-fs-lg); } +:where(h4) { font-size: var(--cudly-fs-md); } +:where(h5, h6) { font-size: var(--cudly-fs-sm); } + /* Default keyboard focus ring. Zero specificity (:where) so a component that * draws its own focus style keeps it; everything else gets a visible ring. */ :where(a[href], button, input, select, textarea, summary, [tabindex]):focus-visible { From 751ea489e6cddfd2caf6c599bdc241ea643fb3f1 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:22:54 +0000 Subject: [PATCH 2/6] refactor(frontend): use the type tokens where a literal size or weight equals one Literal font sizes that are exactly a --cudly-fs-* value (0.75rem, 0.875rem, 1rem, 1.75rem) and every numeric or keyword font weight (bold, 700, 600, 500) now use the matching token, so a later change to the scale reaches them. Pure substitution: every computed value is unchanged. Off-scale sizes (0.8rem, 0.85rem, 0.9rem, ...) are left as they are, since snapping them to the scale is a visible design change. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/base.css | 2 +- frontend/src/styles/components.css | 60 +++++++++++++++--------------- frontend/src/styles/forms.css | 14 +++---- frontend/src/styles/modals.css | 10 ++--- frontend/src/styles/plans.css | 10 ++--- frontend/src/styles/settings.css | 26 ++++++------- frontend/src/styles/tables.css | 8 ++-- frontend/src/styles/tabs.css | 4 +- 8 files changed, 67 insertions(+), 67 deletions(-) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 9dc47417a..bcba031d6 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -246,7 +246,7 @@ textarea { /* Help text */ .help-text { color: var(--cudly-text-muted); - font-size: 0.875rem; + font-size: var(--cudly-fs-sm); margin-bottom: 0.75rem; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5d76ec88e..3d7e306d7 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -87,13 +87,13 @@ .card h3 { color: var(--cudly-text-muted); - font-size: 0.875rem; + font-size: var(--cudly-fs-sm); margin-bottom: 0.5rem; } .card .value { - font-size: 1.75rem; - font-weight: bold; + font-size: var(--cudly-fs-2xl); + font-weight: var(--cudly-fw-bold); } .card .monthly { @@ -123,7 +123,7 @@ button { background: var(--cudly-surface); color: var(--cudly-text); cursor: pointer; - font-size: 0.875rem; + font-size: var(--cudly-fs-sm); transition: background 0.2s, border-color 0.2s; } @@ -195,7 +195,7 @@ button.secondary:hover { background: var(--cudly-surface); color: var(--cudly-text); cursor: pointer; - font-size: 0.875rem; + font-size: var(--cudly-fs-sm); transition: background 0.2s, border-color 0.2s; } @@ -389,7 +389,7 @@ button.secondary:hover { .toast--error { border-left-color: var(--cudly-error-fg); } .toast-icon { - font-weight: bold; + font-weight: var(--cudly-fw-bold); font-size: 1.1rem; line-height: 1.2; } @@ -454,7 +454,7 @@ button.secondary:hover { .btn-download .download-icon { margin-right: 0.4rem; - font-weight: bold; + font-weight: var(--cudly-fw-bold); color: var(--cudly-primary); } @@ -559,7 +559,7 @@ button.secondary:hover { .accounts-overview-chip-count { font-size: 1.4rem; - font-weight: 700; + font-weight: var(--cudly-fw-bold); color: var(--cudly-primary); } @@ -598,7 +598,7 @@ button.secondary:hover { background: var(--cudly-info-bg); border-color: #4a90e2; color: #1a56c4; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } /* Per-provider accounts table (P3). Replaces the prior inline-row @@ -622,7 +622,7 @@ button.secondary:hover { .accounts-table th { font-size: 0.85rem; color: var(--cudly-text-muted); - font-weight: 500; + font-weight: var(--cudly-fw-medium); background: #fafafa; } @@ -688,7 +688,7 @@ th .column-filter-btn:hover { } th .column-filter-btn.active { color: var(--cudly-primary); - font-weight: 700; + font-weight: var(--cudly-fw-bold); } /* Detached popover (lives in document.body, positioned absolutely). */ @@ -707,7 +707,7 @@ th .column-filter-btn.active { .column-filter-popover .column-filter-heading { margin: 0 0 0.5rem 0; font-size: 0.9rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } .column-filter-popover .column-filter-all { display: flex; @@ -716,7 +716,7 @@ th .column-filter-btn.active { padding: 0.25rem 0; border-bottom: 1px solid var(--cudly-border); margin-bottom: 0.5rem; - font-weight: 500; + font-weight: var(--cudly-fw-medium); } .column-filter-popover .column-filter-list { max-height: 240px; @@ -933,7 +933,7 @@ tr.recommendation-row:hover { .column-visibility-popover .column-visibility-title { margin: 0 0 0.4rem; font-size: 0.8em; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: #444; text-transform: uppercase; letter-spacing: 0.04em; @@ -1017,7 +1017,7 @@ tr.rec-cell-summary-row:hover { box-shadow: var(--cudly-focus-ring); } .rec-cell-identity { - font-weight: 500; + font-weight: var(--cudly-fw-medium); font-size: 0.9em; } .rec-cell-range { @@ -1141,8 +1141,8 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .approval-details-stat-value { - font-size: 1rem; - font-weight: 600; + font-size: var(--cudly-fs-md); + font-weight: var(--cudly-fw-semibold); color: #222; } @@ -1171,7 +1171,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { position: sticky; top: 0; background: var(--cudly-surface-2, #f6f8fa); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text-muted, #444); font-size: 0.78rem; text-transform: uppercase; @@ -1193,8 +1193,8 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; + font-size: var(--cudly-fs-xs); + font-weight: var(--cudly-fw-semibold); /* No text-transform: let the HTML control casing. Acronyms (AWS, * GCP) render as-written; brand names (Azure) keep their native * title case rather than being SHOUT-CASED by CSS. */ @@ -1220,7 +1220,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; - font-size: 0.75rem; + font-size: var(--cudly-fs-xs); background: var(--cudly-info-bg); color: var(--cudly-primary); } @@ -1230,7 +1230,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.8rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } .status-badge.active { @@ -1343,8 +1343,8 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { /* Role badge */ .role-badge { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--cudly-fs-xs); + font-weight: var(--cudly-fw-semibold); padding: 0.15rem 0.5rem; margin-left: 0.4rem; border-radius: 999px; @@ -1358,7 +1358,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .badge-admin { display: inline-block; font-size: 0.8rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--cudly-info-bg); @@ -1368,7 +1368,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .badge-user { display: inline-block; font-size: 0.8rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--cudly-border); @@ -1378,7 +1378,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .badge-readonly { display: inline-block; font-size: 0.8rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--cudly-bg); @@ -1393,7 +1393,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .badge { display: inline-block; font-size: 0.8rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); padding: 0.2rem 0.55rem; border-radius: 999px; letter-spacing: 0.01em; @@ -1437,7 +1437,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .history-approver strong { color: var(--cudly-text); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } /* Purchase detail status badges */ @@ -1476,7 +1476,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .copy-btn .copy-icon { - font-size: 1rem; + font-size: var(--cudly-fs-md); } .copy-btn.copied { diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css index e965e1dae..a31ab160e 100644 --- a/frontend/src/styles/forms.css +++ b/frontend/src/styles/forms.css @@ -12,7 +12,7 @@ select { label { display: block; margin-bottom: 0.5rem; - font-weight: 500; + font-weight: var(--cudly-fw-medium); color: var(--cudly-text); } @@ -145,7 +145,7 @@ label { .form-group label { display: block; margin-bottom: 0.5rem; - font-weight: 500; + font-weight: var(--cudly-fw-medium); } .form-group input, @@ -221,7 +221,7 @@ input:checked + .slider:before { } .form-section h3 { - font-size: 1rem; + font-size: var(--cudly-fs-md); color: var(--cudly-primary); margin-bottom: 1rem; } @@ -310,7 +310,7 @@ input:checked + .slider:before { } .ramp-label { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); margin-bottom: 0.25rem; } @@ -382,7 +382,7 @@ input:checked + .slider:before { background: var(--cudly-surface-muted); border: 1px solid #dee2e6; border-radius: 4px; - font-size: 0.875rem; + font-size: var(--cudly-fs-sm); } .requirement { @@ -397,11 +397,11 @@ input:checked + .slider:before { width: 1.25rem; height: 1.25rem; margin-right: 0.5rem; - font-weight: bold; + font-weight: var(--cudly-fw-bold); text-align: center; line-height: 1.25rem; border-radius: 50%; - font-size: 0.75rem; + font-size: var(--cudly-fs-xs); } .requirement.unmet { diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index ac40fa77d..6a730c5e7 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -101,7 +101,7 @@ .modal-hint a { color: var(--cudly-primary); text-decoration: none; - font-weight: 500; + font-weight: var(--cudly-fw-medium); } .modal-hint a:hover { @@ -270,7 +270,7 @@ } .archera-page-lead { - font-size: 1rem; + font-size: var(--cudly-fs-md); color: #555; line-height: 1.6; margin-bottom: 1rem; @@ -335,8 +335,8 @@ text-decoration: none; padding: 0.65rem 1.5rem; border-radius: 4px; - font-size: 1rem; - font-weight: 500; + font-size: var(--cudly-fs-md); + font-weight: var(--cudly-fw-medium); transition: background 0.2s; } @@ -450,7 +450,7 @@ padding: 0.55rem 1.1rem; border-radius: 4px; font-size: 0.92rem; - font-weight: 500; + font-weight: var(--cudly-fw-medium); transition: background 0.2s; } diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 2a8253dbd..952dc6ae3 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -70,7 +70,7 @@ } .plan-detail-value { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); } @@ -126,7 +126,7 @@ .upcoming-date .day { font-size: 1.5rem; - font-weight: bold; + font-weight: var(--cudly-fw-bold); color: var(--cudly-primary); } @@ -162,7 +162,7 @@ .upcoming-savings .amount { font-size: 1.25rem; - font-weight: bold; + font-weight: var(--cudly-fw-bold); color: var(--cudly-success); } @@ -217,7 +217,7 @@ } .ramp-label { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); margin-bottom: 0.25rem; } @@ -272,7 +272,7 @@ #plan-editor-panel p { line-height: 1.5; margin: 12px 0; } #plan-editor-panel .editor-draft-notice { padding: 12px; background: var(--cudly-info-bg); border-radius: 8px; } #plan-editor-panel .editor-help { margin-bottom: 16px; } -#plan-editor-panel .editor-status { font-weight: 600; min-height: 1.5em; } +#plan-editor-panel .editor-status { font-weight: var(--cudly-fw-semibold); min-height: 1.5em; } #plan-editor-panel .schedule-preview-scroll { min-height: 372px; } #plan-editor-panel .schedule-preview-scroll[hidden] { display: none; } #plan-editor-panel [data-editor="changes"] { margin-top: 16px; border-top: 1px solid var(--cudly-border); } diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index ddae897a8..df2f4c70f 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -29,7 +29,7 @@ .sub-tab-btn.active { color: var(--cudly-primary); border-bottom-color: var(--cudly-primary); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } /* Visible keyboard focus on sub-tab buttons (Admin sub-tabs + Inventory @@ -77,7 +77,7 @@ } .settings-category legend { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); font-size: 1.1rem; color: var(--cudly-primary); padding: 0 0.5rem; @@ -112,7 +112,7 @@ .setting-info label, .setting-info .label-like { - font-weight: 500; + font-weight: var(--cudly-fw-medium); color: var(--cudly-text); margin: 0; } @@ -185,7 +185,7 @@ /* Subsection title */ .subsection-title { font-size: 0.95rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); margin: 1.5rem 0 1rem; padding-bottom: 0.5rem; @@ -224,13 +224,13 @@ .service-default-card h5 { margin: 0 0 0.75rem; font-size: 0.9rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); } .service-default-card .service-default-hint { margin: -0.5rem 0 0.75rem; - font-size: 0.75rem; + font-size: var(--cudly-fs-xs); line-height: 1.3; color: var(--cudly-text-muted); } @@ -313,7 +313,7 @@ select.dirty { } .target-cloud-row label { - font-weight: 500; + font-weight: var(--cudly-fw-medium); color: var(--cudly-text); margin: 0; } @@ -330,7 +330,7 @@ select.dirty { border: 1px solid #c8d6ea; background: #f4f8fd; color: #355273; - font-weight: 500; + font-weight: var(--cudly-fw-medium); } .target-cloud-pills .target-cloud-pill[aria-pressed="true"], @@ -358,7 +358,7 @@ select.dirty { } .federation-format-buttons .btn-multiline .label { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } .federation-format-buttons .btn-multiline .sublabel { @@ -396,7 +396,7 @@ select.dirty { } .federation-account-name { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); font-size: 0.9rem; white-space: nowrap; overflow: hidden; @@ -498,9 +498,9 @@ select.dirty { /* Permission matrix */ .permission-matrix { width: 100%; border-collapse: collapse; font-size: 0.85rem; } .permission-matrix th, .permission-matrix td { padding: 0.4rem 0.6rem; border: 1px solid var(--cudly-border); text-align: center; } -.permission-matrix th { background: var(--cudly-bg); font-weight: 600; } +.permission-matrix th { background: var(--cudly-bg); font-weight: var(--cudly-fw-semibold); } .permission-matrix td:first-child { text-align: left; font-family: monospace; } -.permission-matrix .has-perm { color: #137333; font-weight: 600; } +.permission-matrix .has-perm { color: #137333; font-weight: var(--cudly-fw-semibold); } .permission-matrix .no-perm { color: var(--cudly-border-strong); } /* Expandable user rows */ @@ -529,7 +529,7 @@ select.dirty { .expand-panel-perms h5 { margin: 0 0 0.5rem; font-size: 0.85rem; - font-weight: 600; + font-weight: var(--cudly-fw-semibold); color: var(--cudly-text); } .group-checkbox-list { diff --git a/frontend/src/styles/tables.css b/frontend/src/styles/tables.css index 291bcf07c..2589f4d90 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -17,7 +17,7 @@ th, td { th { background: var(--cudly-surface-muted); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } tr:hover { @@ -99,7 +99,7 @@ tr.history-row-highlight:hover { .planned-purchases-table th { background: var(--cudly-surface-muted); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); font-size: 0.85rem; color: var(--cudly-text-muted); } @@ -109,7 +109,7 @@ tr.history-row-highlight:hover { } .planned-purchase-row .plan-name { - font-weight: 500; + font-weight: var(--cudly-fw-medium); display: block; } @@ -147,7 +147,7 @@ tr.history-row-highlight:hover { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; - font-weight: 500; + font-weight: var(--cudly-fw-medium); text-transform: capitalize; } diff --git a/frontend/src/styles/tabs.css b/frontend/src/styles/tabs.css index 37fc7772e..c63879c37 100644 --- a/frontend/src/styles/tabs.css +++ b/frontend/src/styles/tabs.css @@ -14,7 +14,7 @@ border: none; border-bottom: 3px solid transparent; color: var(--cudly-text-muted); - font-size: 1rem; + font-size: var(--cudly-fs-md); cursor: pointer; border-radius: 0; } @@ -27,7 +27,7 @@ .tab-btn.active { color: var(--cudly-primary); border-bottom-color: var(--cudly-primary); - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } .tab-content { From 73385ea89905aeba2e6cfce2c8ade355cabd70e6 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:29:43 +0000 Subject: [PATCH 3/6] fix(frontend): give form controls one size and pin the foundation with tests Inside the Create API Key modal the text input got 14.4px from the .modal-content rule while the password and date inputs fell back to the browser's 13.33px, next to 14px buttons. The modal input rule and the bare `select` rule now use --cudly-fs-sm, so every control in a form matches the buttons (a 0.4px change for those two rules). Tests: a jest suite reads the stylesheets for the body line-height, the control font rule, the heading scale and the absence of token-duplicate literals; a Playwright spec checks the computed input/button font and body line-height in Chromium, which jsdom cannot cascade. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/typography.test.ts | 49 +++++++++++++++++++++++ frontend/src/styles/forms.css | 2 +- frontend/src/styles/modals.css | 2 +- frontend/tests-e2e/typography.spec.ts | 44 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/typography.test.ts create mode 100644 frontend/tests-e2e/typography.spec.ts diff --git a/frontend/src/__tests__/typography.test.ts b/frontend/src/__tests__/typography.test.ts new file mode 100644 index 000000000..b5fd83b6a --- /dev/null +++ b/frontend/src/__tests__/typography.test.ts @@ -0,0 +1,49 @@ +/** + * Typography foundation (issue #741): body line-height, form controls that + * inherit the page font, a base heading scale on the --cudly-fs-* tokens, and + * no literal sizes or weights that duplicate a token. + */ +import fs from 'fs'; +import path from 'path'; + +const stylesDir = path.join(__dirname, '..', 'styles'); +const read = (file: string): string => fs.readFileSync(path.join(stylesDir, file), 'utf8'); +const files = fs.readdirSync(stylesDir).filter(file => file.endsWith('.css')); + +/** Body of the first rule whose selector list matches `selector` exactly. */ +function ruleBody(css: string, selector: RegExp): string { + const match = new RegExp(`(?:^|\\})\\s*${selector.source}\\s*\\{([^}]*)\\}`, 'm').exec(css); + if (!match) throw new Error(`no rule for ${selector.source}`); + return match[1]!; +} + +describe('Typography foundation (issue #741)', () => { + const base = read('base.css'); + + test('body sets the normal line-height token', () => { + expect(ruleBody(base, /body/)).toMatch(/line-height:\s*var\(--cudly-lh-normal\)/); + }); + + test('form controls inherit the page font at the button size', () => { + const body = ruleBody(base, /button,\s*input,\s*select,\s*textarea/); + expect(body).toMatch(/font:\s*inherit/); + expect(body).toMatch(/font-size:\s*var\(--cudly-fs-sm\)/); + }); + + test.each([ + ['h1', 'fs-2xl'], + ['h2', 'fs-xl'], + ['h3', 'fs-lg'], + ['h4', 'fs-md'], + ['h5, h6', 'fs-sm'], + ])('%s has a zero-specificity base size on --cudly-%s', (tags, token) => { + const selector = new RegExp(`:where\\(${tags.replace(/ /g, '\\s*')}\\)`); + expect(ruleBody(base, selector)).toContain(`var(--cudly-${token})`); + }); + + test.each(files)('%s has no literal font size or weight that duplicates a token', file => { + const css = read(file); + expect(css).not.toMatch(/font-size:\s*(0\.75|0\.875|1|1\.125|1\.375|1\.75|2\.25)rem\s*;/); + expect(css).not.toMatch(/font-weight:\s*(bold|400|500|600|700)\s*;/); + }); +}); diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css index a31ab160e..c9dd38743 100644 --- a/frontend/src/styles/forms.css +++ b/frontend/src/styles/forms.css @@ -5,7 +5,7 @@ select { padding: 0.5rem; border: 1px solid var(--cudly-border-strong); border-radius: 4px; - font-size: 0.9rem; + font-size: var(--cudly-fs-sm); background: white; } diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index 6a730c5e7..3c4c14146 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -52,7 +52,7 @@ border: 1px solid var(--cudly-border-strong); border-radius: 4px; margin-top: 0.25rem; - font-size: 0.9rem; + font-size: var(--cudly-fs-sm); } .modal-content textarea { diff --git a/frontend/tests-e2e/typography.spec.ts b/frontend/tests-e2e/typography.spec.ts new file mode 100644 index 000000000..a95c4d431 --- /dev/null +++ b/frontend/tests-e2e/typography.spec.ts @@ -0,0 +1,44 @@ +/** + * Typography foundation (issue #741). jsdom does not cascade stylesheets, so + * the jest suite can only read the CSS text; these checks read what Chromium + * actually computes for a real form (Create API Key modal). + */ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +test.beforeEach(async ({ page }) => { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/users', (r) => r.fulfill({ json: { users: [] } })); + await page.route('**/api/groups', (r) => r.fulfill({ json: { groups: [] } })); + await page.route('**/api/api-keys', (r) => r.fulfill({ json: { keys: [] } })); + await page.goto('/admin/users'); + await page.locator('#create-apikey-btn').click(); + await expect(page.locator('#create-apikey-modal')).toBeVisible(); +}); + +test('form inputs use the page font at the same size as the adjacent buttons', async ({ page }) => { + const font = (selector: string) => + page.locator(selector).evaluate((el) => { + const cs = getComputedStyle(el); + return { family: cs.fontFamily, size: cs.fontSize }; + }); + const body = await page.evaluate(() => getComputedStyle(document.body).fontFamily); + const button = await font('#close-create-apikey-modal-btn'); + + // Text, password and date inputs used to get three different sizes + // (14.4px from a modal rule, 13.33px browser default for the others). + for (const id of ['#apikey-name', '#apikey-password', '#apikey-expires-at']) { + const input = await font(id); + expect(input.family, id).toBe(body); + expect(input.size, id).toBe(button.size); + } +}); + +test('body text uses the normal line-height token', async ({ page }) => { + const { lineHeight, fontSize } = await page.evaluate(() => { + const cs = getComputedStyle(document.body); + return { lineHeight: parseFloat(cs.lineHeight), fontSize: parseFloat(cs.fontSize) }; + }); + expect(lineHeight / fontSize).toBeCloseTo(1.5, 2); +}); From b77227637b4a9b9dbba2150d924da1d333ef2c46 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:40:22 +0000 Subject: [PATCH 4/6] fix(frontend): render form help text as a block .form-help is used on elements, which are inline by default. With the new 1.5 body line-height their wrapped lines sat on the 16px parent line box and ended up 24px apart, and their margin-top never applied. As a block they get their own 0.8rem line box and the intended gap below the input. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/forms.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css index c9dd38743..ed5b060e1 100644 --- a/frontend/src/styles/forms.css +++ b/frontend/src/styles/forms.css @@ -157,7 +157,11 @@ label { border-radius: 4px; } +/* Block, not the inline default: inline it sat on the 16px body + * line box, so with the 1.5 body line-height its wrapped lines were 24px + * apart, and its margin-top had no effect. */ .form-help { + display: block; font-size: 0.8rem; color: var(--cudly-text-muted); margin-top: 0.25rem; From 25c29183da91400aa99d55f67a86c728999add04 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 17:34:39 +0000 Subject: [PATCH 5/6] fix(frontend): tokenize the font size and weight main added after the merge Main added .account-delete-pending-total { font-weight: 600 } (#1373) and .last-updated-stamp { font-size: 0.75rem } (#1314). Both equal a design token exactly, so the typography test that pins 'no literal that duplicates a token' failed after merging main. They now use --cudly-fw-semibold and --cudly-fs-xs; computed values are unchanged. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/components.css | 2 +- frontend/src/styles/settings.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index a8ccce078..7719d10d9 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1116,7 +1116,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .account-delete-pending-total { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); } /* Approval-details modal (issue #374): when the confirmDialog body diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 041180fe4..2d3080de7 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -630,7 +630,7 @@ tr.override-disabled { display: block; margin-top: 0.125rem; color: var(--cudly-text-muted); - font-size: 0.75rem; + font-size: var(--cudly-fs-xs); white-space: nowrap; } @media (max-width: 768px) { From 9ffff64c814dc80e0b0961a88e8ac1d2384809c2 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 21:01:37 +0000 Subject: [PATCH 6/6] fix(frontend): use typography tokens in the ladder timeline rules main added Main's #1387 added .ladder-timeline-controls label (font-weight 500) and .ladder-timeline-stat (font-weight 600) to charts.css. Swap them for --cudly-fw-medium and --cudly-fw-semibold; computed values are unchanged. Refs #741 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/charts.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index ee4d34850..d41dd5ba2 100644 --- a/frontend/src/styles/charts.css +++ b/frontend/src/styles/charts.css @@ -108,12 +108,12 @@ .ladder-timeline-controls .form-group label { margin-bottom: var(--cudly-sp-1); font-size: var(--cudly-fs-xs); - font-weight: 500; + font-weight: var(--cudly-fw-medium); color: var(--cudly-text-muted); } .ladder-timeline-controls input[data-date] { width: 11.5rem; font-variant-numeric: tabular-nums; } .ladder-timeline-state { margin: 0 0 var(--cudly-sp-3); } -.ladder-timeline-stat { margin: var(--cudly-sp-3) 0 var(--cudly-sp-1); font-weight: 600; } +.ladder-timeline-stat { margin: var(--cudly-sp-3) 0 var(--cudly-sp-1); font-weight: var(--cudly-fw-semibold); } .ladder-timeline-note { margin: 0 0 var(--cudly-sp-2); font-size: var(--cudly-fs-sm); color: var(--cudly-text-muted); } .ladder-timeline-chart { height: 320px; margin: 1rem 0; } #ladder-purchase-timeline [data-table] { overflow-x: auto; }