diff --git a/app/src/__tests__/tint-contrast.test.ts b/app/src/__tests__/tint-contrast.test.ts new file mode 100644 index 00000000..c258161e --- /dev/null +++ b/app/src/__tests__/tint-contrast.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { compileString } from 'sass'; +import { VIEW_ACCENTS } from '../view-accents'; + +// 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); + }); + } + } + } + + // Brand views replace --accent and --accent-text on the panel root, which also + // changes the selected-row tint under every other pill. + const base = theme('amber'); + for (const mode of ['dark', 'light'] as const) { + for (const [view, brand] of Object.entries(VIEW_ACCENTS[mode])) { + for (const tone of TONES) { + it(`${view} view ${mode}: --${tone}-text keeps 4.5:1 on its tint`, () => { + const tokens = { ...base[mode], accent: brand.accent, 'accent-text': brand.text }; + 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..6ff52a77 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -38,6 +38,7 @@ import { angularNativePage, type PlatformPage } from './native-page'; import { initialTab, storeTab, storedTab } from './tab-memory'; import { detectBaseURL } from './base-url'; import { clearHighlightsOnHide } from './rpc'; +import { viewAccent } from './view-accents'; const HUB_VIEWS = [ 'angular', @@ -59,22 +60,6 @@ const VIEW_TITLE: Record = { capacitor: 'Capacitor', }; -const VIEW_ACCENT: Partial> = { - ngrx: '#d770e8', - analog: '#ff5470', - 'angular-native': '#ff4d6d', - nativescript: '#8196ff', - capacitor: '#53b9ff', -}; - -const VIEW_ACCENT_LIGHT: Partial> = { - ngrx: '#a21caf', - analog: '#be123c', - 'angular-native': '#be123c', - nativescript: '#3448c5', - capacitor: '#0369a1', -}; - const VIEW_TAB: Partial> = { ngrx: 'store', analog: 'analog' }; const TAB_VIEW: Partial> = { store: 'ngrx', analog: 'analog' }; const VIEW_TABS: Partial> = { @@ -162,7 +147,8 @@ function readView(): View | null { @Component({ selector: 'app-root', host: { - '[style.--accent]': 'viewAccent()', + '[style.--accent]': 'viewAccent()?.accent ?? null', + '[style.--accent-text]': 'viewAccent()?.text ?? null', '(window:message)': 'inspectFromPanel($event)', }, imports: [ @@ -782,11 +768,7 @@ export class App implements OnInit, OnDestroy { { id: 'network', label: 'SSR & HTTP' }, ]; readonly view = signal(readView()); - readonly viewAccent = computed(() => { - const view = this.view(); - const accents = this.themeService.current() === 'light' ? VIEW_ACCENT_LIGHT : VIEW_ACCENT; - return (view && accents[view]) ?? null; - }); + readonly viewAccent = computed(() => viewAccent(this.view(), this.themeService.current())); readonly title = computed(() => { const view = this.view(); return view ? VIEW_TITLE[view] : 'Pangular Inspector'; diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index a75faa68..a3f57987 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -1131,9 +1131,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 { @@ -1344,7 +1346,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 { @@ -1685,7 +1687,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; @@ -1805,30 +1807,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 { @@ -1838,19 +1830,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; @@ -1870,7 +1856,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'] { @@ -1879,7 +1865,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'], @@ -1889,7 +1875,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'] { @@ -1898,7 +1884,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 0ee7cbc7..6561644b 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -876,14 +876,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; @@ -1070,7 +1070,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; @@ -1188,7 +1188,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); @@ -1321,14 +1321,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 277c3a96..db8bc6b6 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -80,10 +80,12 @@ interface InjectorTreeState extends InjectorPage { 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)', }; @@ -567,7 +569,7 @@ function isTree(value: unknown): value is InjectorNode[] { font-size: 13px; @include m.light { - --directive: #1d4ed8; + --directive: #1e40af; } } .mono { @@ -945,7 +947,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; @@ -1123,7 +1125,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; @@ -1307,7 +1309,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 76abaeec..6361909d 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -716,7 +716,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; @@ -874,7 +874,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; @@ -887,12 +887,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); @@ -1205,7 +1205,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 2559f091..38f38729 100644 --- a/app/src/pages/forms-report.ts +++ b/app/src/pages/forms-report.ts @@ -201,7 +201,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 ff75ea2d..b4e2e9e1 100644 --- a/app/src/pages/forms-types.ts +++ b/app/src/pages/forms-types.ts @@ -351,17 +351,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 f455c6ef..c18bfe1e 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -1239,7 +1239,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; @@ -1720,13 +1720,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; @@ -1737,10 +1737,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 380d8bf7..d3667cb9 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -618,7 +618,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); @@ -754,10 +754,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; @@ -995,10 +995,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 2fcc0f2c..2d74ea4f 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 13bb7c12..bc114704 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -1019,7 +1019,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; @@ -1070,13 +1070,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/app/src/view-accents.ts b/app/src/view-accents.ts new file mode 100644 index 00000000..222ea219 --- /dev/null +++ b/app/src/view-accents.ts @@ -0,0 +1,31 @@ +// Brand colors for the hub views that are not plain Angular. `accent` replaces +// `--accent` on that view; `text` replaces `--accent-text`, the color for text on +// an accent tint (pills, badges), chosen to keep 4.5:1 on hovered and selected rows. +export type BrandView = 'ngrx' | 'analog' | 'angular-native' | 'nativescript' | 'capacitor'; + +export interface ViewAccent { + accent: string; + text: string; +} + +export const VIEW_ACCENTS: Record<'dark' | 'light', Record> = { + dark: { + ngrx: { accent: '#d770e8', text: '#f0abfc' }, + analog: { accent: '#ff5470', text: '#fda4af' }, + 'angular-native': { accent: '#ff4d6d', text: '#fda4af' }, + nativescript: { accent: '#8196ff', text: '#a5b4fc' }, + capacitor: { accent: '#53b9ff', text: '#7dd3fc' }, + }, + light: { + ngrx: { accent: '#a21caf', text: '#701a75' }, + analog: { accent: '#be123c', text: '#881337' }, + 'angular-native': { accent: '#be123c', text: '#881337' }, + nativescript: { accent: '#3448c5', text: '#3730a3' }, + capacitor: { accent: '#0369a1', text: '#0c4a6e' }, + }, +}; + +export function viewAccent(view: string | null, theme: 'dark' | 'light'): ViewAccent | null { + const accents: Partial> = VIEW_ACCENTS[theme]; + return (view && accents[view]) || null; +} diff --git a/docs/contributing/ui-guidelines.md b/docs/contributing/ui-guidelines.md index 5e3879c0..c68667f8 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,15 +27,18 @@ 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; brand views set their own value | | `--radius`, `--radius-sm` | Cards, controls | | `--control-h` | 34px, the height of every input, select and button | | `--ease`, `--font-mono` | Motion curve, code and tokens | -Brand colors belong only to the brand they represent: NgRx keeps its purple on the NgRx view, the Angular view uses the Angular gradient for its logo and title, and Analog, NativeScript and Capacitor use their own colors on their pages. Each one has a darker light-theme value (`VIEW_ACCENT_LIGHT` in `app/src/app.ts`). Everything else is amber. +Brand colors belong only to the brand they represent: NgRx keeps its purple on the NgRx view, the Angular view uses the Angular gradient for its logo and title, and Analog, NativeScript and Capacitor use their own colors on their pages. Each one has a dark and a light value in `app/src/view-accents.ts`: `accent` replaces `--accent` on that view, and `text` replaces `--accent-text` and must keep 4.5:1 on its own tint over every surface and selected row (`app/src/__tests__/tint-contrast.test.ts` checks it). Everything else is amber. ## 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 `