From 5884ac64f6d34fe85982083d76bd02a90bf0d8cf Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 04:02:51 +0000 Subject: [PATCH 1/2] fix(ui): keep tinted pill text at 4.5:1 on hovered and selected rows Status and accent pills used the base tone as text on a 12 to 16% tint of the same tone. That passes on a plain surface, but drops below 4.5:1 when the pill sits on a pressed or hovered button (surface-3) or a selected row (accent tint). Measured on the live panel: a deferred block error pill on a hovered row was 4.34:1 in light, and the DI component letter on a selected row was 4.36:1 in light. Add per-theme text-on-tint tokens (--ok-text, --warn-text, --danger-text, --accent-text, the last from a new `text` key on each accent) and an m.tint($tone) mixin that uses them. Status and accent m.soft calls and the hand-written tone pills now use the mixin or the tokens. On brand views --accent-text follows the view accent like --accent. Analog's info pill and GET method and DI's light directive color get darker light values. A panel test compiles the theme for every accent and checks each -text token against its tint on every surface and selected row in both themes. --- app/src/__tests__/tint-contrast.test.ts | 85 ++++++++ app/src/app.ts | 1 + app/src/pages/analog-inspector.ts | 44 ++-- app/src/pages/component-tree.ts | 14 +- app/src/pages/di-inspector.ts | 14 +- app/src/pages/forms-inspector.ts | 10 +- app/src/pages/forms-report.ts | 2 +- app/src/pages/forms-types.ts | 6 +- app/src/pages/live-route.ts | 2 +- app/src/pages/network-inspector.ts | 12 +- app/src/pages/pipes-inspector.ts | 10 +- app/src/pages/route-current.ts | 2 +- app/src/pages/route-lint.ts | 2 +- app/src/pages/router-types.ts | 6 +- app/src/pages/signal-inspector.ts | 8 +- app/src/styles/_mixins.scss | 9 + app/src/styles/_palette.scss | 20 ++ app/src/styles/_theme.scss | 10 + docs/contributing/ui-guidelines.md | 7 +- ...=> browser-agent-rpc-BXhoSh1z-Dm-KSsSg.js} | 2 +- extension/ui/assets/index-7mfkZAtT.css | 1 - extension/ui/assets/index-BJvGA0C8.css | 1 + .../{index-CgvJVwtz.js => index-DvLuQlmw.js} | 189 +++++++++--------- extension/ui/index.html | 4 +- 24 files changed, 286 insertions(+), 175 deletions(-) create mode 100644 app/src/__tests__/tint-contrast.test.ts rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-CQUbrXfP.js => browser-agent-rpc-BXhoSh1z-Dm-KSsSg.js} (93%) delete mode 100644 extension/ui/assets/index-7mfkZAtT.css create mode 100644 extension/ui/assets/index-BJvGA0C8.css rename extension/ui/assets/{index-CgvJVwtz.js => index-DvLuQlmw.js} (97%) diff --git a/app/src/__tests__/tint-contrast.test.ts b/app/src/__tests__/tint-contrast.test.ts new file mode 100644 index 00000000..6f3afd51 --- /dev/null +++ b/app/src/__tests__/tint-contrast.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { compileString } from 'sass'; + +// Tinted pills (`m.tint`, data-tone badges, DI kind letters) put `---text` +// on a 12 to 16% tint of the tone. The pill can sit on a plain, hovered or +// pressed surface, or on a selected row (`--accent` at 12% or 16%). The text must +// keep 4.5:1 on all of them, in both themes and with every accent. + +type Rgb = [number, number, number]; +type Tokens = Record; + +const STYLES = new URL('../styles/', import.meta.url).pathname; +const ACCENTS = ['amber', 'ember', 'gold']; +const TONES = ['ok', 'warn', 'danger', 'accent']; + +function theme(accent: string): { dark: Tokens; light: Tokens } { + const { css } = compileString(`@use 'theme' with ($accent: ${accent});`, { + loadPaths: [STYLES], + }); + const read = (selector: string): Tokens => { + const start = css.indexOf(`${selector} {`); + expect(start, selector).toBeGreaterThanOrEqual(0); + const body = css.slice(start, css.indexOf('}', start)); + const tokens: Tokens = {}; + for (const [, name, value] of body.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6})\s*;/gi)) { + tokens[name] = value; + } + return tokens; + }; + return { dark: read(':root'), light: read(`:root[data-theme=light]`) }; +} + +const rgb = (hex: string): Rgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as Rgb; +const channel = (v: number) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; +const luminance = ([r, g, b]: Rgb) => + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +const ratio = (a: Rgb, b: Rgb) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; +const mix = (top: Rgb, amount: number, under: Rgb): Rgb => + top.map((v, i) => v * amount + under[i] * (1 - amount)) as Rgb; + +function worst(tokens: Tokens, tone: string) { + const under: [string, Rgb][] = ['bg', 'surface', 'surface-2', 'surface-3'].map((k) => [ + k, + rgb(tokens[k]), + ]); + for (const amount of [12, 16]) { + under.push([ + `a selected row (accent ${amount}%)`, + mix(rgb(tokens['accent']), amount / 100, rgb(tokens['surface'])), + ]); + } + let min = { ratio: Infinity, on: '' }; + for (const amount of [12, 16]) { + for (const [name, backdrop] of under) { + const tint = mix(rgb(tokens[tone]), amount / 100, backdrop); + const value = ratio(rgb(tokens[`${tone}-text`]), tint); + if (value < min.ratio) + min = { ratio: value, on: `--${tone}-text on ${amount}% tint over ${name}` }; + } + } + return min; +} + +describe('text on tinted pills', () => { + for (const accent of ACCENTS) { + const themes = theme(accent); + for (const mode of ['dark', 'light'] as const) { + for (const tone of TONES) { + it(`${accent} ${mode}: --${tone}-text keeps 4.5:1 on its tint`, () => { + const tokens = themes[mode]; + expect(tokens[`${tone}-text`], `--${tone}-text`).toBeDefined(); + const min = worst(tokens, tone); + expect(min.ratio, min.on).toBeGreaterThanOrEqual(4.5); + }); + } + } + } +}); diff --git a/app/src/app.ts b/app/src/app.ts index 98e3bb9f..8fc932ef 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -163,6 +163,7 @@ function readView(): View | null { selector: 'app-root', host: { '[style.--accent]': 'viewAccent()', + '[style.--accent-text]': 'viewAccent()', '(window:message)': 'inspectFromPanel($event)', }, imports: [ diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index afe562c8..890800d4 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -1061,9 +1061,11 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth --good: var(--ok); --bad: var(--danger); --info: #60a5fa; + --info-text: #bfdbfe; @include m.light { --info: #1d4ed8; + --info-text: #1e40af; } } :host { @@ -1274,7 +1276,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth .count[data-tone='warn'], [role='tab'][aria-selected='true'] .count[data-tone='warn'] { background: color-mix(in srgb, var(--warn) 12%, transparent); - color: var(--warn); + color: var(--warn-text); } .panel { @@ -1615,7 +1617,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth .pill.live { border-color: var(--accent-line); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); - color: var(--accent); + color: var(--accent-text); } .pill[data-kind='layout'] { border-color: #94a3b8; @@ -1735,30 +1737,20 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } } [data-tone='good'].pill { - border-color: color-mix(in srgb, var(--ok) 30%, transparent); - background: color-mix(in srgb, var(--ok) 12%, transparent); - color: var(--ok); + @include m.tint(ok); } [data-tone='warn'].pill, [data-tone='warn'].chip { - border-color: color-mix(in srgb, var(--warn) 30%, transparent); - background: color-mix(in srgb, var(--warn) 12%, transparent); - color: var(--warn); + @include m.tint(warn); } [data-tone='bad'].pill, [data-tone='bad'].chip { - border-color: color-mix(in srgb, var(--danger) 30%, transparent); - background: color-mix(in srgb, var(--danger) 12%, transparent); - color: var(--danger); + @include m.tint(danger); } [data-tone='info'].pill { border-color: color-mix(in srgb, var(--info) 30%, transparent); background: color-mix(in srgb, var(--info) 12%, transparent); - color: #bfdbfe; - - @include m.light { - color: var(--info); - } + color: var(--info-text); } .status { @@ -1768,19 +1760,13 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth font-variant-numeric: tabular-nums; } .status[data-status='good'] { - border-color: color-mix(in srgb, var(--ok) 30%, transparent); - background: color-mix(in srgb, var(--ok) 12%, transparent); - color: var(--ok); + @include m.tint(ok); } .status[data-status='warn'] { - border-color: color-mix(in srgb, var(--warn) 30%, transparent); - background: color-mix(in srgb, var(--warn) 12%, transparent); - color: var(--warn); + @include m.tint(warn); } .status[data-status='bad'] { - border-color: color-mix(in srgb, var(--danger) 30%, transparent); - background: color-mix(in srgb, var(--danger) 12%, transparent); - color: var(--danger); + @include m.tint(danger); } .method { min-width: 52px; @@ -1800,7 +1786,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @include m.light { border-color: color-mix(in srgb, #0369a1 30%, transparent); background: color-mix(in srgb, #0369a1 8%, transparent); - color: #0369a1; + color: #075985; } } .method[data-method='POST'] { @@ -1809,7 +1795,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth color: #86efac; @include m.light { - color: var(--ok); + color: var(--ok-text); } } .method[data-method='PUT'], @@ -1819,7 +1805,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth color: #fde68a; @include m.light { - color: var(--warn); + color: var(--warn-text); } } .method[data-method='DELETE'] { @@ -1828,7 +1814,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth color: #fecaca; @include m.light { - color: var(--danger); + color: var(--danger-text); } } .request { diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 200f61fc..46ea90e9 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -873,14 +873,14 @@ function bare(name: string): string { line-height: 18px; } .defer-state-complete { - @include m.soft(var(--ok)); + @include m.tint(ok); } .defer-state-error { - @include m.soft(var(--danger)); + @include m.tint(danger); } .defer-state-loading, .defer-state-placeholder { - @include m.soft(var(--warn)); + @include m.tint(warn); } .triggers { min-width: 0; @@ -1067,7 +1067,7 @@ function bare(name: string): string { .badge { padding: 2px 8px; border-radius: 99px; - @include m.soft(var(--accent)); + @include m.tint(accent); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; @@ -1185,7 +1185,7 @@ function bare(name: string): string { font-size: 12px; } .chip.on { - @include m.soft(var(--ok)); + @include m.tint(ok); } .chip-note { color: var(--text-2); @@ -1318,14 +1318,14 @@ function bare(name: string): string { flex: none; padding: 1px 8px; border-radius: 99px; - @include m.soft(var(--accent)); + @include m.tint(accent); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } .kind-flag.directive { - @include m.soft(var(--ok)); + @include m.tint(ok); } .row-main { display: flex; diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index 0f9a3d66..98c2e2d3 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -63,10 +63,12 @@ type TreeView = 'element' | 'environment'; const NULL_ID = 'inj-null'; +// Text tones: these color the kind letter and badge on a 14 to 16% tint of +// themselves, so they use the stronger `-text` tokens. const KIND_TONE: Record = { - component: 'var(--accent)', + component: 'var(--accent-text)', directive: 'var(--directive)', - environment: 'var(--ok)', + environment: 'var(--ok-text)', null: 'var(--text-3)', }; @@ -527,7 +529,7 @@ function isTree(value: unknown): value is InjectorNode[] { font-size: 13px; @include m.light { - --directive: #1d4ed8; + --directive: #1e40af; } } .mono { @@ -889,7 +891,7 @@ function isTree(value: unknown): value is InjectorNode[] { font-size: 11px; } .count.provides { - @include m.soft(var(--ok)); + @include m.tint(ok); } .count svg { width: 11px; @@ -1067,7 +1069,7 @@ function isTree(value: unknown): value is InjectorNode[] { line-height: 16px; } .kind-flag { - @include m.soft(var(--accent)); + @include m.tint(accent); } .dep-meta { display: flex; @@ -1251,7 +1253,7 @@ function isTree(value: unknown): value is InjectorNode[] { font-size: 11px; padding: 1px 8px; border-radius: 99px; - @include m.soft(var(--ok)); + @include m.tint(ok); border-width: 1px; border-style: solid; } diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index cd2c272c..81519342 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -697,7 +697,7 @@ function countFields(node: FormFieldNode): number { border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); border-radius: 99px; background: color-mix(in srgb, var(--danger) 12%, var(--surface)); - color: var(--danger); + color: var(--danger-text); font-size: 11px; font-weight: 600; line-height: 18px; @@ -878,7 +878,7 @@ function countFields(node: FormFieldNode): number { border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); border-radius: 99px; background: color-mix(in srgb, var(--ok) 12%, var(--surface)); - color: var(--ok); + color: var(--ok-text); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; @@ -891,12 +891,12 @@ function countFields(node: FormFieldNode): number { .badge[data-status='INVALID'] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: color-mix(in srgb, var(--danger) 12%, var(--surface)); - color: var(--danger); + color: var(--danger-text); } .badge[data-status='PENDING'] { border-color: color-mix(in srgb, var(--warn) 30%, transparent); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); - color: var(--warn); + color: var(--warn-text); } .badge[data-status='DISABLED'] { border-color: var(--border-strong); @@ -1209,7 +1209,7 @@ function countFields(node: FormFieldNode): number { .flags span.warn { border-color: color-mix(in srgb, var(--warn) 30%, transparent); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); - color: var(--warn); + color: var(--warn-text); } .errors { min-width: 180px; diff --git a/app/src/pages/forms-report.ts b/app/src/pages/forms-report.ts index 2552298a..f9b3e7ba 100644 --- a/app/src/pages/forms-report.ts +++ b/app/src/pages/forms-report.ts @@ -198,7 +198,7 @@ export class FormsSubmit { margin-bottom: 4px; border-radius: 50%; background: color-mix(in srgb, var(--ok) 12%, transparent); - color: var(--ok); + color: var(--ok-text); } .summary { margin: 0; diff --git a/app/src/pages/forms-types.ts b/app/src/pages/forms-types.ts index d7d34ba0..fdd5d3c9 100644 --- a/app/src/pages/forms-types.ts +++ b/app/src/pages/forms-types.ts @@ -329,17 +329,17 @@ export const FORMS_STYLES = ` .tag[data-tone='ok'] { border-color: color-mix(in srgb, var(--ok) 30%, transparent); background: color-mix(in srgb, var(--ok) 12%, var(--surface)); - color: var(--ok); + color: var(--ok-text); } .tag[data-tone='warn'] { border-color: color-mix(in srgb, var(--warn) 30%, transparent); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); - color: var(--warn); + color: var(--warn-text); } .tag[data-tone='bad'] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: color-mix(in srgb, var(--danger) 12%, var(--surface)); - color: var(--danger); + color: var(--danger-text); } .status { margin: 0; diff --git a/app/src/pages/live-route.ts b/app/src/pages/live-route.ts index c91da05b..daf8f358 100644 --- a/app/src/pages/live-route.ts +++ b/app/src/pages/live-route.ts @@ -261,7 +261,7 @@ type TabId = (typeof TABS)[number]['id']; border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); border-radius: 99px; background: color-mix(in srgb, var(--danger) 12%, var(--surface)); - color: var(--danger); + color: var(--danger-text); font-size: 11px; font-weight: 600; line-height: 16px; diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index d78005a1..5277afd4 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -1219,7 +1219,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ flex-direction: column; align-items: flex-start; gap: 4px; - @include m.soft(var(--danger)); + @include m.tint(danger); } .state.error strong { font-weight: 600; @@ -1696,13 +1696,13 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ white-space: nowrap; } .tag.server { - @include m.soft(var(--accent)); + @include m.tint(accent); } .tag.fault { - @include m.soft(var(--danger)); + @include m.tint(danger); } .tag.mock { - @include m.soft(var(--accent)); + @include m.tint(accent); } .tag.rule-ref { gap: 4px; @@ -1713,10 +1713,10 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ font-family: var(--font-mono); } .tag.on { - @include m.soft(var(--ok)); + @include m.tint(ok); } .tag.off-tag { - @include m.soft(var(--warn)); + @include m.tint(warn); } pre { margin: 0; diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 100c716d..cda9b926 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -604,7 +604,7 @@ const KIND_OPTIONS: readonly SelectOption[] = [ background: var(--text-3); } .record.on { - @include m.soft(var(--accent)); + @include m.tint(accent); } .record.on .rec-dot { background: var(--accent); @@ -740,10 +740,10 @@ const KIND_OPTIONS: readonly SelectOption[] = [ } .chip.impure, .chip.warn { - @include m.soft(var(--warn)); + @include m.tint(warn); } .chip.live { - @include m.soft(var(--ok)); + @include m.tint(ok); } .pill { display: inline-flex; @@ -974,10 +974,10 @@ const KIND_OPTIONS: readonly SelectOption[] = [ text-transform: uppercase; } .severity[data-tone='warning'] { - @include m.soft(var(--warn)); + @include m.tint(warn); } .severity[data-tone='error'] { - @include m.soft(var(--danger)); + @include m.tint(danger); } .rule { color: var(--accent); diff --git a/app/src/pages/route-current.ts b/app/src/pages/route-current.ts index 85eac2cd..fe36a693 100644 --- a/app/src/pages/route-current.ts +++ b/app/src/pages/route-current.ts @@ -254,7 +254,7 @@ interface OutletRow { border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--warn) 10%, transparent); - color: var(--warn); + color: var(--warn-text); font-size: 13px; } .pending-text { diff --git a/app/src/pages/route-lint.ts b/app/src/pages/route-lint.ts index 681c03db..66fd7d41 100644 --- a/app/src/pages/route-lint.ts +++ b/app/src/pages/route-lint.ts @@ -148,7 +148,7 @@ import { border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); border-radius: 99px; background: color-mix(in srgb, var(--ok) 12%, transparent); - color: var(--ok); + color: var(--ok-text); } .findings { display: grid; diff --git a/app/src/pages/router-types.ts b/app/src/pages/router-types.ts index 4f3d349b..837427c6 100644 --- a/app/src/pages/router-types.ts +++ b/app/src/pages/router-types.ts @@ -279,17 +279,17 @@ export const SHARED_STYLES = ` .badge[data-tone='good'] { border-color: color-mix(in srgb, var(--ok) 30%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); - color: var(--ok); + color: var(--ok-text); } .badge[data-tone='warn'] { border-color: color-mix(in srgb, var(--warn) 30%, transparent); background: color-mix(in srgb, var(--warn) 12%, transparent); - color: var(--warn); + color: var(--warn-text); } .badge[data-tone='bad'] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); - color: var(--danger); + color: var(--danger-text); } button.small { display: inline-flex; diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index 1e301ebb..27013695 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -1006,7 +1006,7 @@ const KIND_COLORS: Record = { font-variant-numeric: tabular-nums; } .changed-badge { - @include m.soft(var(--warn)); + @include m.tint(warn); } .detail-panel { margin-top: 8px; @@ -1057,13 +1057,13 @@ const KIND_COLORS: Record = { border-radius: 99px; } .tone-ok { - @include m.soft(var(--ok)); + @include m.tint(ok); } .tone-warn { - @include m.soft(var(--warn)); + @include m.tint(warn); } .tone-danger { - @include m.soft(var(--danger)); + @include m.tint(danger); } .tone-neutral { color: var(--text-2); diff --git a/app/src/styles/_mixins.scss b/app/src/styles/_mixins.scss index 77c3ecb4..610194ee 100644 --- a/app/src/styles/_mixins.scss +++ b/app/src/styles/_mixins.scss @@ -35,6 +35,15 @@ color: #{$color}; } +// Tinted pill, badge or chip in a theme tone (ok, warn, danger or accent). +// The text uses the stronger `---text` token so it keeps 4.5:1 when the +// pill sits on a hovered or selected row. +@mixin tint($tone) { + border-color: color-mix(in srgb, var(--#{$tone}) 30%, transparent); + background: color-mix(in srgb, var(--#{$tone}) 12%, transparent); + color: var(--#{$tone}-text); +} + @mixin truncate { overflow: hidden; min-width: 0; diff --git a/app/src/styles/_palette.scss b/app/src/styles/_palette.scss index f318f563..5f29e0c6 100644 --- a/app/src/styles/_palette.scss +++ b/app/src/styles/_palette.scss @@ -31,30 +31,36 @@ $accents: ( base: #f5a524, hover: #fbbf4d, ink: #1c1300, + text: #fbbf4d, light: ( base: #92400e, hover: #a04510, ink: #ffffff, + text: #78350f, ), ), ember: ( base: #fb7a3c, hover: #fd9a67, ink: #1f0a00, + text: #fd9a67, light: ( base: #9a3412, hover: #b04016, ink: #ffffff, + text: #7c2d12, ), ), gold: ( base: #eab308, hover: #facc15, ink: #1a1400, + text: #facc15, light: ( base: #713f12, hover: #854d0e, ink: #ffffff, + text: #5c330e, ), ), ); @@ -71,6 +77,20 @@ $status-light: ( danger: #b91c1c, ); +// Text on a 12 to 16% tint of the matching status color (pills, badges, chips). +// Stronger than the base so it keeps 4.5:1 on hovered and selected rows. +$status-text: ( + ok-text: #4ade80, + warn-text: #fbbf24, + danger-text: #fca5a5, +); + +$status-text-light: ( + ok-text: #14532d, + warn-text: #78350f, + danger-text: #991b1b, +); + @function accent($name, $key, $subkey: null) { $a: map.get($accents, $name); @if $subkey { diff --git a/app/src/styles/_theme.scss b/app/src/styles/_theme.scss index 2f3a4fd3..85fc9dca 100644 --- a/app/src/styles/_theme.scss +++ b/app/src/styles/_theme.scss @@ -17,6 +17,7 @@ $ease: cubic-bezier(0.2, 0.8, 0.2, 1); --accent: #{p.accent($accent, base)}; --accent-hover: #{p.accent($accent, hover)}; --accent-ink: #{p.accent($accent, ink)}; + --accent-text: #{p.accent($accent, text)}; --accent-soft: color-mix(in srgb, var(--accent) 12%, transparent); --accent-line: color-mix(in srgb, var(--accent) 45%, transparent); @@ -24,6 +25,10 @@ $ease: cubic-bezier(0.2, 0.8, 0.2, 1); --#{$name}: #{$value}; } + @each $name, $value in p.$status-text { + --#{$name}: #{$value}; + } + --radius: #{$radius}; --radius-sm: #{$radius-sm}; --control-h: 34px; @@ -44,11 +49,16 @@ $ease: cubic-bezier(0.2, 0.8, 0.2, 1); --accent: #{p.accent($accent, light, base)}; --accent-hover: #{p.accent($accent, light, hover)}; --accent-ink: #{p.accent($accent, light, ink)}; + --accent-text: #{p.accent($accent, light, text)}; @each $name, $value in p.$status-light { --#{$name}: #{$value}; } + @each $name, $value in p.$status-text-light { + --#{$name}: #{$value}; + } + --shadow: 0 2px 12px -4px rgba(0, 0, 0, 0.12); } diff --git a/docs/contributing/ui-guidelines.md b/docs/contributing/ui-guidelines.md index 5e3879c0..727d555c 100644 --- a/docs/contributing/ui-guidelines.md +++ b/docs/contributing/ui-guidelines.md @@ -14,7 +14,7 @@ The palette lives in `app/src/styles/_palette.scss` and becomes CSS variables in ); ``` -Accents available: `amber` (default), `ember`, `gold`. Add one by extending `$accents` in `_palette.scss`, with a `light` map (`base`, `hover`, `ink`) whose `base` passes 4.5:1 on `--surface-3` in the light theme. Light neutrals and status colors are `$neutrals-light` and `$status-light`. +Accents available: `amber` (default), `ember`, `gold`. Add one by extending `$accents` in `_palette.scss` with `base`, `hover`, `ink` and `text`, plus a `light` map with the same keys whose `base` passes 4.5:1 on `--surface-3` in the light theme. Light neutrals and status colors are `$neutrals-light` and `$status-light`. The text-on-tint colors are `$status-text` and `$status-text-light`, plus each accent's `text`. Always use the variables, never hex values: @@ -27,6 +27,8 @@ Always use the variables, never hex values: | `--accent`, `--accent-hover`, `--accent-ink` | Primary actions and selection; `--accent-ink` is text on an accent fill | | `--accent-soft`, `--accent-line` | Selected backgrounds, focus glow, accent borders | | `--ok`, `--warn`, `--danger` | Status | +| `--ok-text`, `--warn-text`, `--danger-text` | Text on a tint of the matching status color (pills, badges, chips) | +| `--accent-text` | Text on an accent tint; follows the view accent on brand views | | `--radius`, `--radius-sm` | Cards, controls | | `--control-h` | 34px, the height of every input, select and button | | `--ease`, `--font-mono` | Motion curve, code and tokens | @@ -35,7 +37,8 @@ Brand colors belong only to the brand they represent: NgRx keeps its purple on t ## Shared building blocks -- **SCSS mixins** in `app/src/styles/_mixins.scss`, used with `@use 'mixins' as m;` in component styles: `m.focus-ring($offset)`, `m.field-focus`, `m.panel($level)`, `m.label`, `m.soft($color)`, `m.truncate`, `m.enter($duration)`, and `m.light` for a light-theme override of a page-only color (it covers both `data-theme='light'` and the system preference). +- **SCSS mixins** in `app/src/styles/_mixins.scss`, used with `@use 'mixins' as m;` in component styles: `m.focus-ring($offset)`, `m.field-focus`, `m.panel($level)`, `m.label`, `m.tint($tone)`, `m.soft($color)`, `m.truncate`, `m.enter($duration)`, and `m.light` for a light-theme override of a page-only color (it covers both `data-theme='light'` and the system preference). +- **Tinted pills**: a status or accent pill, badge or chip uses `@include m.tint(ok)` (or `warn`, `danger`, `accent`). It tints the background with the tone and sets the text to `---text`, which keeps 4.5:1 when the pill sits on a hovered or selected row. A hand-written tint uses the `-text` token for its text, never the base tone. Keep `m.soft($color)` for page-only colors, and check them yourself. - **Dropdown**: `app/src/ui/select.ts` (``). Never use a native `