diff --git a/client/src/components/brain/tabs/BrainGraph.jsx b/client/src/components/brain/tabs/BrainGraph.jsx index b22b255870..7834071ca0 100644 --- a/client/src/components/brain/tabs/BrainGraph.jsx +++ b/client/src/components/brain/tabs/BrainGraph.jsx @@ -6,6 +6,8 @@ import {AlertTriangle, Zap, RefreshCw, X, ChevronRight, ArrowLeft, Compass, Info import toast from '../../ui/Toast'; import * as api from '../../../services/api'; import { BRAIN_TYPE_HEX, DESTINATIONS } from '../constants'; +import { chipColors } from '../../../lib/chipContrast'; +import { useThemeContext } from '../../ThemeContext'; import { buildGraph } from '../../../lib/graphSimulation'; import { pickNearestNodeByScreenDistance, isTapGesture } from '../../../lib/graphPicking'; import { pushFocus, popFocus, currentFocusId } from '../../../lib/brainGraphFocus'; @@ -24,6 +26,21 @@ const EDGE_COLORS = { const BRAIN_TYPES = ['people', 'projects', 'ideas', 'admin', 'memories', 'songs', 'goals', 'journals']; +/** + * Inline chip style for a brain-type badge. `BRAIN_TYPE_HEX` is a fixed + * category palette picked against the dark graph canvas, so painting it + * verbatim as TEXT on the theme-following tooltip/detail panels fails WCAG AA + * on the day themes (`ideas` #eab308 lands near 1.7:1 on a light card). + * `chipColors` keeps each type's hue and moves only the lightness. + * + * Returns undefined for an unknown type so the badge falls back to its plain + * bordered look instead of an inline `color: undefined`. + * + * The badge must not also carry an `!important` theme utility (`text-gray-*`, + * `border-port-border`) — those beat the inline declaration. + */ +const brainTypeChipStyle = (brainType, mode) => chipColors(BRAIN_TYPE_HEX[brainType], mode) || undefined; + // Only a gesture on the WebGL canvas itself picks a node. The overlay chrome — // "Clear selection", the legend toggle, the loading veil — sits INSIDE the same // wrapper the touch handlers are bound to, so its taps bubble there too; without @@ -203,6 +220,7 @@ export default function BrainGraph() { // mouse move from re-rendering this component when nothing can paint. const { hoveredNode, tooltipPos, handleHover, handlePointerMove } = useHoverTooltip(); const { visible: touchHintVisible, showOnFirstTouch } = useFirstTouchHint(); + const { theme } = useThemeContext(); const [layoutKey, setLayoutKey] = useState(0); const [syncing, setSyncing] = useState(false); const [confirmingRefresh, setConfirmingRefresh] = useState(false); @@ -738,7 +756,7 @@ export default function BrainGraph() {
{DESTINATIONS[hoveredNode.brainType]?.label || hoveredNode.brainType} @@ -762,7 +780,7 @@ export default function BrainGraph() {
{DESTINATIONS[selectedNode.brainType]?.label || selectedNode.brainType} diff --git a/client/src/components/brain/tabs/BrainGraph.test.jsx b/client/src/components/brain/tabs/BrainGraph.test.jsx index 9555e1811a..9559e88eb1 100644 --- a/client/src/components/brain/tabs/BrainGraph.test.jsx +++ b/client/src/components/brain/tabs/BrainGraph.test.jsx @@ -15,6 +15,14 @@ vi.mock('@react-three/fiber', () => ({ })); vi.mock('@react-three/drei', () => ({ OrbitControls: () => null })); +// Brain-type badges grade their category hex against the ACTIVE theme mode, so +// the mode has to be steerable per test. The real provider runs a settings +// fetch on mount, which this suite has no business exercising. +const { themeMode } = vi.hoisted(() => ({ themeMode: { current: 'night' } })); +vi.mock('../../ThemeContext', () => ({ + useThemeContext: () => ({ theme: { mode: themeMode.current } }), +})); + vi.mock('../../../services/api', () => ({ getBrainGraph: vi.fn(), getBrainGraphSearchIndex: vi.fn(), @@ -31,6 +39,8 @@ vi.mock('../../../services/api', () => ({ })); import * as api from '../../../services/api'; +import { chipColors, parseColor } from '../../../lib/chipContrast'; +import { BRAIN_TYPE_HEX } from '../constants'; import BrainGraph, { recordBody } from './BrainGraph'; const GRAPH = { @@ -141,6 +151,34 @@ describe('detail panel', () => { // DOCUMENT_POSITION_FOLLOWING === the body comes after the button. expect(explore.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + + // `BRAIN_TYPE_HEX` is tuned for the near-black graph canvas, but this panel + // follows the theme — `goals` #f97316 as verbatim text lands well under AA on + // a day card. The AA math itself is `lib/chipContrast.test.js`'s job; what + // this owns is that the badge is graded for the ACTIVE mode, not a fixed one. + it.each(['day', 'night'])('grades the brain-type badge for the %s theme mode', async (mode) => { + themeMode.current = mode; + const user = userEvent.setup(); + api.getBrainGraphSearchIndex.mockResolvedValue({ + nodes: [{ id: 'n1', label: 'Alpha', brainType: 'ideas' }], + }); + await renderGraph(); + await selectConnectedNode(user); + + // "Goals" also names a type-filter toggle in the header; only the detail + // badge carries an inline ink. + const badge = screen.getAllByText('Goals').find((el) => el.style.color); + expect(badge, 'no brain-type badge carries a graded inline color').toBeDefined(); + const other = mode === 'day' ? 'night' : 'day'; + // parseColor on both sides: jsdom normalizes an inline `#rrggbb` to `rgb(…)`. + expect(parseColor(badge.style.color)) + .toEqual(parseColor(chipColors(BRAIN_TYPE_HEX.goals, mode).color)); + expect(parseColor(badge.style.color)) + .not.toEqual(parseColor(chipColors(BRAIN_TYPE_HEX.goals, other).color)); + // The graded style is inline, so the badge must not also carry a theme + // utility that `index.css` remaps with `!important`. + expect(badge.className).not.toMatch(/(^|\s)(text-white|text-gray-\d00|border-port-border)(\s|$)/); + }); }); describe('canvas sizing', () => { diff --git a/client/src/components/calendar/ChronotypeOverlay.jsx b/client/src/components/calendar/ChronotypeOverlay.jsx index cd6fdab968..d4b911ef48 100644 --- a/client/src/components/calendar/ChronotypeOverlay.jsx +++ b/client/src/components/calendar/ChronotypeOverlay.jsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react'; import * as api from '../../services/api'; +import { chipColors } from '../../lib/chipContrast'; +import { useThemeContext } from '../ThemeContext'; /** * ChronotypeOverlay renders colored energy zone bands and marker lines @@ -9,9 +11,16 @@ import * as api from '../../services/api'; * - Dashed marker lines for cutoffs (caffeine, last meal) * - Labels that appear on hover via CSS (pointer-events-none so * calendar events remain clickable through the overlay) + * + * The band/marker-line fills stay the zone's raw color — they're large tinted + * areas, and the tint IS the signal. The LABELS are text, so they run through + * `chipContrast`: the amber zone (#f59e0b) is ~2.1:1 on a day theme's card, + * i.e. a live WCAG AA failure. Grading keeps the hue and moves only the + * lightness, so a label still reads as its own zone. */ export default function ChronotypeOverlay({ startHour, pxPerHour }) { const [schedule, setSchedule] = useState(null); + const { theme } = useThemeContext(); useEffect(() => { api.getChronotypeEnergySchedule().then(setSchedule).catch(() => null); @@ -19,6 +28,10 @@ export default function ChronotypeOverlay({ startHour, pxPerHour }) { if (!schedule?.zones?.length) return null; + // An unparseable zone color yields no graded style — keep the raw color + // rather than dropping the zone's identity entirely. + const labelColor = (color) => chipColors(color, theme?.mode)?.color || color; + const startMinutes = startHour * 60; const minToTop = (min) => ((min - startMinutes) / 60) * pxPerHour; @@ -42,7 +55,7 @@ export default function ChronotypeOverlay({ startHour, pxPerHour }) { /> {zone.label} @@ -67,7 +80,7 @@ export default function ChronotypeOverlay({ startHour, pxPerHour }) { > {zone.label} diff --git a/client/src/components/calendar/DayView.jsx b/client/src/components/calendar/DayView.jsx index 4b27af6bd4..a1bef7d7f6 100644 --- a/client/src/components/calendar/DayView.jsx +++ b/client/src/components/calendar/DayView.jsx @@ -4,9 +4,10 @@ import * as api from '../../services/api'; import socket from '../../services/socket'; import EventDetail from './EventDetail'; import ChronotypeOverlay from './ChronotypeOverlay'; -import { buildSubcalendarColorMap } from './calendarUtils'; +import { buildSubcalendarColorMap, eventChipStyle } from './calendarUtils'; import { formatDateFull } from '../../utils/formatters'; import BrailleSpinner from '../BrailleSpinner'; +import { useThemeContext } from '../ThemeContext'; import useUrlParams from '../../hooks/useUrlParams'; const START_HOUR = 6; @@ -107,6 +108,7 @@ export default function DayView({ accounts }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [searchParams, updateParams] = useUrlParams(); + const { theme } = useThemeContext(); const fetchEvents = useCallback(async () => { const startDate = date.toISOString(); @@ -190,10 +192,7 @@ export default function DayView({ accounts }) { key={`${event.accountId}-${event.id}`} onClick={() => updateParams({ event: `${event.accountId}:${event.id}` })} className="w-full text-left px-3 py-2 rounded text-sm transition-colors hover:brightness-125" - style={{ - backgroundColor: adColor ? `${adColor}20` : 'rgb(59 130 246 / 0.1)', - color: adColor || 'var(--port-accent, #3b82f6)' - }} + style={eventChipStyle(adColor, theme?.mode)} > {event.title} @@ -246,11 +245,13 @@ export default function DayView({ accounts }) { minHeight: PX_PER_15MIN, left: `calc(${leftPercent}% + 2px)`, width: `calc(${widthPercent}% - 4px)`, - borderLeftColor: eventColor || 'var(--port-accent, #3b82f6)', - backgroundColor: eventColor ? `${eventColor}25` : 'rgb(59 130 246 / 0.2)' + ...eventChipStyle(eventColor, theme?.mode) }} > -
{event.title}
+ {/* Title inherits the graded color from the block. It must NOT + carry `text-white`: day mode remaps that utility with + `!important`, which beats the inline color. */} +
{event.title}
{height > 32 && event.location && (
{event.location} diff --git a/client/src/components/calendar/MonthView.jsx b/client/src/components/calendar/MonthView.jsx index ba34085f91..411aecd88e 100644 --- a/client/src/components/calendar/MonthView.jsx +++ b/client/src/components/calendar/MonthView.jsx @@ -3,8 +3,9 @@ import {ChevronLeft, ChevronRight} from 'lucide-react'; import * as api from '../../services/api'; import socket from '../../services/socket'; import EventDetail from './EventDetail'; -import { buildSubcalendarColorMap } from './calendarUtils'; +import { buildSubcalendarColorMap, eventChipStyle } from './calendarUtils'; import BrailleSpinner from '../BrailleSpinner'; +import { useThemeContext } from '../ThemeContext'; import { formatMonthYear, formatTimeOfDay } from '../../utils/formatters'; import useUrlParams from '../../hooks/useUrlParams'; @@ -41,6 +42,7 @@ export default function MonthView({ accounts }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [searchParams, updateParams] = useUrlParams(); + const { theme } = useThemeContext(); const cells = getMonthGrid(year, month); const monthLabel = formatMonthYear(new Date(year, month)); @@ -151,10 +153,7 @@ export default function MonthView({ accounts }) { key={`${event.accountId}-${event.id}`} onClick={() => updateParams({ event: `${event.accountId}:${event.id}` })} className="w-full text-left px-1 py-0.5 rounded text-[10px] truncate transition-colors hover:brightness-125" - style={{ - backgroundColor: evColor ? `${evColor}20` : 'rgb(59 130 246 / 0.15)', - color: evColor || 'var(--port-accent, #3b82f6)' - }} + style={eventChipStyle(evColor, theme?.mode)} > {!event.isAllDay && ( diff --git a/client/src/components/calendar/WeekView.jsx b/client/src/components/calendar/WeekView.jsx index fb102a75b4..ab902b70be 100644 --- a/client/src/components/calendar/WeekView.jsx +++ b/client/src/components/calendar/WeekView.jsx @@ -4,8 +4,9 @@ import * as api from '../../services/api'; import socket from '../../services/socket'; import EventDetail from './EventDetail'; import ChronotypeOverlay from './ChronotypeOverlay'; -import { buildSubcalendarColorMap } from './calendarUtils'; +import { buildSubcalendarColorMap, eventChipStyle } from './calendarUtils'; import BrailleSpinner from '../BrailleSpinner'; +import { useThemeContext } from '../ThemeContext'; import { formatMonthDay, formatWeekdayShort, formatDateShort } from '../../utils/formatters'; import useUrlParams from '../../hooks/useUrlParams'; @@ -114,6 +115,7 @@ export default function WeekView({ accounts }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [searchParams, updateParams] = useUrlParams(); + const { theme } = useThemeContext(); const weekDays = getWeekDays(weekStart); const weekEnd = new Date(weekStart); @@ -235,10 +237,7 @@ export default function WeekView({ accounts }) { key={eventKey(event)} onClick={() => updateParams({ event: `${event.accountId}:${event.id}` })} className="w-full text-left px-1 py-0.5 rounded text-[10px] truncate transition-colors hover:brightness-125" - style={{ - backgroundColor: adColor ? `${adColor}20` : 'rgb(59 130 246 / 0.15)', - color: adColor || 'var(--port-accent, #3b82f6)' - }} + style={eventChipStyle(adColor, theme?.mode)} > {event.title} @@ -300,11 +299,13 @@ export default function WeekView({ accounts }) { minHeight: PX_PER_15MIN, left: `calc(${leftPercent}% + 1px)`, width: `calc(${widthPercent}% - 2px)`, - borderLeftColor: evColor || 'var(--port-accent, #3b82f6)', - backgroundColor: evColor ? `${evColor}25` : 'rgb(59 130 246 / 0.2)' + ...eventChipStyle(evColor, theme?.mode) }} > -
{event.title}
+ {/* Title inherits the graded color from the block. It must NOT + carry `text-white`: day mode remaps that utility with + `!important`, which beats the inline color. */} +
{event.title}
); })} diff --git a/client/src/components/calendar/calendarEventChips.test.jsx b/client/src/components/calendar/calendarEventChips.test.jsx new file mode 100644 index 0000000000..3c349f1ab3 --- /dev/null +++ b/client/src/components/calendar/calendarEventChips.test.jsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; + +// One suite for the three views because the thing under test is one contract +// shared across them: a subcalendar color is external Google Calendar data, so +// every chip that paints it as TEXT has to run it through `chipContrast` for the +// ACTIVE theme mode. Per-view files would triple the socket/api/theme scaffold +// to assert the same two lines. + +const { socketMock } = vi.hoisted(() => ({ + socketMock: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, +})); +vi.mock('../../services/socket', () => ({ default: socketMock })); + +const { themeMode } = vi.hoisted(() => ({ themeMode: { current: 'night' } })); +vi.mock('../ThemeContext', () => ({ + useThemeContext: () => ({ theme: { mode: themeMode.current } }), +})); + +vi.mock('../../services/api', () => ({ + getCalendarEvents: vi.fn(), + getChronotypeEnergySchedule: vi.fn(), +})); + +import * as api from '../../services/api'; +import { chipColors, parseColor } from '../../lib/chipContrast'; +import MonthView from './MonthView'; +import WeekView from './WeekView'; +import DayView from './DayView'; +import ChronotypeOverlay from './ChronotypeOverlay'; + +// A pale entry from Google's own subcalendar palette — the class of color that +// rendered near-invisible on the day themes. +const SUBCALENDAR_COLOR = '#fbd75b'; +const ACCOUNTS = [{ subcalendars: [{ calendarId: 'cal-1', color: SUBCALENDAR_COLOR }] }]; + +const at = (hour) => { + const d = new Date(); + d.setHours(hour, 0, 0, 0); + return d.toISOString(); +}; + +const ALL_DAY = { + id: 'e1', accountId: 'acct-1', subcalendarId: 'cal-1', + title: 'Quarter Close', isAllDay: true, startTime: at(0), endTime: at(23), +}; +const TIMED = { + id: 'e2', accountId: 'acct-1', subcalendarId: 'cal-1', + title: 'Design Review', isAllDay: false, startTime: at(10), endTime: at(11), +}; + +const renderView = async (ui) => { + render({ui}); + // Settle the mount-effect fetch inside act (see src/test/setup.js). + await act(async () => {}); +}; + +const chipFor = (title) => screen.getByRole('button', { name: new RegExp(title) }); + +/** The graded color for the ACTIVE mode, and for the other one. */ +const expectGradedForActiveMode = (element, rawColor) => { + const other = themeMode.current === 'day' ? 'night' : 'day'; + // parseColor on both sides: jsdom normalizes an inline `#rrggbb` into + // `rgb(…)`, so comparing the raw strings would pass no matter which mode was + // used to grade it. + expect(parseColor(element.style.color)) + .toEqual(parseColor(chipColors(rawColor, themeMode.current).color)); + expect(parseColor(element.style.color)) + .not.toEqual(parseColor(chipColors(rawColor, other).color)); +}; + +// `index.css` remaps these with `!important`, and author `!important` beats an +// inline declaration — so a chip that carries both renders in theme neutrals +// with its graded color silently dead. Day mode's remap covers `text-white` +// too, which is what made the timed-event titles ignore the block's color. +const IMPORTANT_UTILITIES = /(^|\s)(bg-port-bg|border-port-border|text-white|text-gray-\d00)(\s|$)/; +const IMPORTANT_TEXT_UTILITIES = /(^|\s)(text-white|text-gray-\d00)(\s|$)/; + +const expectNoImportantUtilityUnderGrading = (container) => { + for (const el of container.querySelectorAll('[style]')) { + if (!el.style.color && !el.style.backgroundColor) continue; + expect(el.className, `${el.tagName} carries a graded style AND an !important theme utility`) + .not.toMatch(IMPORTANT_UTILITIES); + } +}; + +/** + * The graded color lives on the chip; the title is often a child that inherits + * it. A child carrying `text-white`/`text-gray-*` overrides that inheritance + * with `!important` on day mode — so assert every element that owns the title + * text node is free of them. + */ +const expectTitleInheritsGrading = (chip, title) => { + const owners = [chip, ...chip.querySelectorAll('*')].filter((el) => Array.from(el.childNodes) + .some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.includes(title))); + expect(owners.length, `no element renders "${title}"`).toBeGreaterThan(0); + for (const el of owners) { + expect(el.className, `"${title}" is painted by an !important theme utility, not the graded color`) + .not.toMatch(IMPORTANT_TEXT_UTILITIES); + } +}; + +beforeEach(() => { + vi.clearAllMocks(); + themeMode.current = 'night'; + api.getCalendarEvents.mockResolvedValue({ events: [ALL_DAY, TIMED] }); + api.getChronotypeEnergySchedule.mockResolvedValue(null); +}); + +afterEach(cleanup); + +describe.each([ + ['MonthView', (accounts) => , ['Quarter Close']], + ['WeekView', (accounts) => , ['Quarter Close', 'Design Review']], + ['DayView', (accounts) => , ['Quarter Close', 'Design Review']], +])('%s event chips', (_name, renderTarget, titles) => { + it.each(['day', 'night'])('grades the subcalendar color for the %s theme mode', async (mode) => { + themeMode.current = mode; + await renderView(renderTarget(ACCOUNTS)); + + for (const title of titles) expectGradedForActiveMode(chipFor(title), SUBCALENDAR_COLOR); + }); + + it('falls back to the accent chip when the subcalendar has no color', async () => { + await renderView(renderTarget([])); + + for (const title of titles) { + // `var(--port-accent, #3b82f6)` was the old fallback and is not a color — + // `--port-accent` is a bare RGB triple, so it has to be wrapped in `rgb()`. + expect(chipFor(title).style.color).toMatch(/^rgb\(var\(--port-accent/); + } + }); + + it('never ships a graded inline style alongside an !important theme utility', async () => { + themeMode.current = 'day'; + const { container } = render({renderTarget(ACCOUNTS)}); + await act(async () => {}); + expectNoImportantUtilityUnderGrading(container); + for (const title of titles) expectTitleInheritsGrading(chipFor(title), title); + }); +}); + +describe('ChronotypeOverlay zone labels', () => { + const ZONES = { + zones: [ + { id: 'z1', label: 'Peak Focus', color: '#f59e0b', startMin: 9 * 60, endMin: 11 * 60, opacity: 0.12 }, + { id: 'z2', label: 'Caffeine Cutoff', color: '#f59e0b', startMin: 14 * 60, marker: true }, + ], + }; + + it.each(['day', 'night'])('grades the label ink for the %s theme mode', async (mode) => { + themeMode.current = mode; + api.getChronotypeEnergySchedule.mockResolvedValue(ZONES); + await renderView(); + + // The amber zone is ~2.1:1 on a day card — the live AA failure this fixes. + for (const label of ['Peak Focus', 'Caffeine Cutoff']) { + expectGradedForActiveMode(screen.getByText(label), '#f59e0b'); + } + }); + + it('keeps the band fill on the zone\'s own raw color', async () => { + api.getChronotypeEnergySchedule.mockResolvedValue(ZONES); + const { container } = render(); + await act(async () => {}); + // The band is a large tint, not text — grading it would shift the wash the + // zone is recognized by, and it carries no ink of its own. + const band = container.querySelector('div[style*="opacity"]'); + expect(parseColor(band.style.backgroundColor)).toEqual(parseColor('#f59e0b')); + }); +}); diff --git a/client/src/components/calendar/calendarUtils.js b/client/src/components/calendar/calendarUtils.js index 665d3ddcc8..3a3af53b73 100644 --- a/client/src/components/calendar/calendarUtils.js +++ b/client/src/components/calendar/calendarUtils.js @@ -1,3 +1,42 @@ +import { chipColors } from '../../lib/chipContrast'; + +/** + * Neutral chip for an event whose subcalendar has no color (or one we can't + * parse). `--port-accent` is a space-separated RGB triple, so it only becomes a + * color inside `rgb()` — the older `var(--port-accent, #3b82f6)` idiom resolved + * to the literal `59 130 246`, which is not a valid color, so the browser + * dropped the declaration and the text quietly inherited instead of painting + * the accent. + */ +const ACCENT = 'rgb(var(--port-accent, 59 130 246))'; +const NEUTRAL_EVENT_STYLE = Object.freeze({ + color: ACCENT, + borderColor: ACCENT, + backgroundColor: 'rgb(var(--port-accent, 59 130 246) / 0.15)', +}); + +/** + * Inline style for one calendar event chip/block: `{ color, borderColor, + * backgroundColor }` ready to spread into a `style` prop. + * + * A subcalendar color is Google Calendar data — picked by whoever created the + * subcalendar, against whatever surface their calendar app uses — so it can't + * be painted verbatim as text: Google's palette includes several pale entries + * that land near 1:1 against a day theme's card. `chipColors` keeps the hue and + * moves only the lightness until it clears WCAG AA on the ACTIVE theme mode. + * + * Callers must NOT also put an `!important` theme utility (`text-white`, + * `text-gray-*`, `bg-port-bg`, `border-port-border`) on the element carrying + * this style — `index.css` remaps those with `!important`, which beats an inline + * declaration and would silently kill the graded color. + * + * @param {string|null|undefined} color subcalendar color (hex) + * @param {'day'|'night'|undefined} mode active theme mode + */ +export function eventChipStyle(color, mode) { + return chipColors(color, mode) || NEUTRAL_EVENT_STYLE; +} + /** * Build a Map of subcalendarId → color from the accounts array. * Used by Day, Week, and Month views for event color coding. diff --git a/client/src/components/calendar/calendarUtils.test.js b/client/src/components/calendar/calendarUtils.test.js new file mode 100644 index 0000000000..4797f9fe76 --- /dev/null +++ b/client/src/components/calendar/calendarUtils.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; + +import { buildSubcalendarColorMap, eventChipStyle } from './calendarUtils'; +import { chipColors, parseColor, contrastRatio, chipBackdrop } from '../../lib/chipContrast'; + +describe('buildSubcalendarColorMap', () => { + it('flattens every account\'s colored subcalendars into one id → color map', () => { + const map = buildSubcalendarColorMap([ + { subcalendars: [{ calendarId: 'a', color: '#fef2c0' }, { calendarId: 'b', color: null }] }, + { subcalendars: [{ calendarId: 'c', color: '#3b82f6' }] }, + ]); + expect(map.get('a')).toBe('#fef2c0'); + expect(map.get('c')).toBe('#3b82f6'); + // A subcalendar with no color is omitted, so the caller's `|| null` branch + // (the neutral chip) is what runs. + expect(map.has('b')).toBe(false); + }); + + it('tolerates missing accounts / subcalendars', () => { + expect(buildSubcalendarColorMap(undefined).size).toBe(0); + expect(buildSubcalendarColorMap([{}]).size).toBe(0); + }); +}); + +describe('eventChipStyle', () => { + // Google's subcalendar palette includes several pale entries; #fbd75b is one + // of the pale ones the day themes rendered at ~1.2:1. + const PALE = '#fbd75b'; + + it('grades the subcalendar color for the mode it is handed', () => { + expect(eventChipStyle(PALE, 'day')).toEqual(chipColors(PALE, 'day')); + // parseColor on both sides — the point is that the two modes disagree, and + // a hardcoded mode would make them identical. + expect(parseColor(eventChipStyle(PALE, 'day').color)) + .not.toEqual(parseColor(eventChipStyle(PALE, 'night').color)); + }); + + it('clears WCAG AA against the chip\'s own backdrop on both modes', () => { + for (const mode of ['day', 'night']) { + const graded = parseColor(eventChipStyle(PALE, mode).color); + expect(contrastRatio(graded, chipBackdrop(parseColor(PALE), mode))).toBeGreaterThanOrEqual(4.5); + } + }); + + it('falls back to the accent chip when there is no usable color', () => { + for (const missing of [null, undefined, '', 'rebeccapurple']) { + const style = eventChipStyle(missing, 'day'); + // Regression guard for `var(--port-accent, #3b82f6)`: `--port-accent` is a + // space-separated triple, so that idiom resolved to `59 130 246` — not a + // color — and the browser dropped the declaration entirely. + expect(style.color).toMatch(/^rgb\(var\(--port-accent/); + expect(style.borderColor).toMatch(/^rgb\(var\(--port-accent/); + expect(style.backgroundColor).toMatch(/^rgb\(var\(--port-accent/); + } + }); +}); diff --git a/client/src/components/cos/TerminalCoSPanel.jsx b/client/src/components/cos/TerminalCoSPanel.jsx index 8526b92f6b..4fd066689c 100644 --- a/client/src/components/cos/TerminalCoSPanel.jsx +++ b/client/src/components/cos/TerminalCoSPanel.jsx @@ -1,9 +1,19 @@ import { Play, Square } from 'lucide-react'; import { AGENT_STATES } from './constants'; import { formatClockTime } from '../../utils/formatters'; +import { chipColors } from '../../lib/chipContrast'; +import { useThemeContext } from '../ThemeContext'; export default function TerminalCoSPanel({ state, speaking, statusMessage, eventLogs, running, onStart, onStop, stats }) { const stateConfig = AGENT_STATES[state] || AGENT_STATES.sleeping; + const { theme } = useThemeContext(); + // `AGENT_STATES` is an intentional 7-way category palette tuned for near-black + // surfaces, and this panel's is theme-following (`--port-terminal-bg`) — so + // `thinking`'s amber renders at ~2.1:1 on a day theme. Grade it: hue stays + // (that's what distinguishes the states), lightness moves until it clears AA. + // Every theme's terminal surface is at least as favorable as the reference + // surface `chipContrast` grades against, so the guarantee carries over. + const asciiColor = chipColors(stateConfig.color, theme?.mode)?.color || stateConfig.color; // Terminal-style ASCII art for the character - alien design const terminalAscii = { @@ -132,7 +142,7 @@ export default function TerminalCoSPanel({ state, speaking, statusMessage, event
{line}
diff --git a/client/src/components/cos/TerminalCoSPanel.test.jsx b/client/src/components/cos/TerminalCoSPanel.test.jsx new file mode 100644 index 0000000000..8337165c4a --- /dev/null +++ b/client/src/components/cos/TerminalCoSPanel.test.jsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +const { themeMode } = vi.hoisted(() => ({ themeMode: { current: 'night' } })); +vi.mock('../ThemeContext', () => ({ + useThemeContext: () => ({ theme: { mode: themeMode.current } }), +})); + +import { AGENT_STATES } from './constants'; +import { chipColors, parseColor, contrastRatio, chipBackdrop } from '../../lib/chipContrast'; +import TerminalCoSPanel from './TerminalCoSPanel'; + +const renderPanel = (state) => render( + {}} + onStop={() => {}} + stats={{}} + /> +); + +// The ASCII art rows are the only elements painted with the state color. +const asciiRows = (container) => [...container.querySelectorAll('div[style*="color"]')]; + +beforeEach(() => { themeMode.current = 'night'; }); +afterEach(cleanup); + +describe('TerminalCoSPanel state color', () => { + // `AGENT_STATES` is an intentional 7-way category palette tuned for near-black + // surfaces, but this panel's surface follows the theme — `thinking`'s amber + // (#f59e0b) renders at ~2.1:1 on a day theme. This is the #1909 follow-up. + it.each(['day', 'night'])('grades the state color for the %s theme mode', (mode) => { + themeMode.current = mode; + const { container } = renderPanel('thinking'); + + const rows = asciiRows(container); + expect(rows.length).toBeGreaterThan(0); + const other = mode === 'day' ? 'night' : 'day'; + for (const row of rows) { + // parseColor on both sides: jsdom normalizes an inline `#rrggbb` to `rgb(…)`, + // so raw string comparison would pass no matter which mode was used. + expect(parseColor(row.style.color)) + .toEqual(parseColor(chipColors(AGENT_STATES.thinking.color, mode).color)); + expect(parseColor(row.style.color)) + .not.toEqual(parseColor(chipColors(AGENT_STATES.thinking.color, other).color)); + } + }); + + it('clears WCAG AA in both modes for every agent state', () => { + for (const mode of ['day', 'night']) { + themeMode.current = mode; + for (const [state, config] of Object.entries(AGENT_STATES)) { + cleanup(); + const { container } = renderPanel(state); + const ink = parseColor(asciiRows(container)[0].style.color); + expect(contrastRatio(ink, chipBackdrop(parseColor(config.color), mode)), `${state} on ${mode}`) + .toBeGreaterThanOrEqual(4.5); + } + } + }); + + it('still labels the state from the same config entry', () => { + renderPanel('thinking'); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/lib/README.md b/client/src/lib/README.md index a20ede6221..3b98930b37 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -100,7 +100,7 @@ grep -i "what you want to do" client/src/lib/README.md | `youtubeUrl.js` | `isYoutubeVideoUrl(text)` / `youtubeVideoId(url)` — single-video YouTube URL detection. Client mirror of `YOUTUBE_INGEST_URL_RE` in `server/services/youtubeIngest.js` (behavioral parity pinned by `server/lib/youtubeUrl.mirror.test.js`); deliberately excludes playlists and channels. Quick Capture uses it to swap to the ingest submit path and reveal the ingest options panel. Also `INGEST_OPTIONS` — the single table of the three ingest artifacts (`key` / `settingKey` / `fallback` / `label` / `hint`) — plus `defaultIngestOptions()` and `ingestOptionsFromSettings(settings)`. Everything that enumerates the switches derives from that table; add an artifact there, not at each call site. | | `boundedMap.js` | `evictOldest(map, max, onEvict?)` — evict oldest-first (`map.keys().next().value`) until a Map is within `max`, with an optional per-evicted-key teardown callback for a companion timer map; `ORPHAN_BUFFER_MAX` (64), the shared cap for the orphan-event race-buffers. The one home for the "stash an unmatched event in a `jobId → …` Map, evict the oldest on overflow" idiom shared by `useSceneRenderLifecycle`, `useImageGenQueue`, and `clientErrorReporter`. | | `buildStamp.js` | Does the bundle this browser loaded come from the same git commit the API is running (#4694)? `SERVED_BUILD_ID` (the server-injected build-id meta tag; null under `npm run dev`) is the trust gate — `BUNDLE_STAMP` is the raw `__BUILD_STAMP__` Vite define, `TRUSTED_BUNDLE_STAMP` is it only when the page came from a real build, since the define is frozen at dev-server start while HMR serves every commit since. **Display or compare `TRUSTED_BUNDLE_STAMP`, never `BUNDLE_STAMP`.** `compareBuildStamps(bundle, server)` → `match`/`mismatch`/`unknown` (absent on either side is always `unknown`); `resolveBuildFrame(signals, {embeddedBuildId})` → `reload`/`drift`/`null`, the two staleness kinds that need different remedies; `createBuildDriftWatcher({embeddedBuildId, fetchIdentity, onShow, onClear})` is the tested state machine that merges the pushed bundle hash with the fetched commit and latches once per kind; `describeBuild({commit, branch, dirty})` renders one line. Consumed by `services/socket.js`, `components/Layout.jsx`, and `components/system-resources/BuildStampPanel.jsx`. | -| `chipContrast.js` | Contrast-safe chip colors for arbitrary, externally-supplied colors — forge issue-label hexes first (`apps/tabs/IssuesTab.jsx`). `chipColors(color, mode)` → `{ color, borderColor, backgroundColor }` for an inline `style` (null when the color is missing/unparseable, so the caller falls back to a neutral chip): the wash keeps the label's own color, while the text/border keep its HUE and move only its LIGHTNESS, by the smallest step that clears WCAG AA against the chip's own tint on the ACTIVE theme mode. Memoized per (color, mode). Graded against `SURFACES` — the worst-case surface per mode (darkest `day`, lightest `night`), which the test re-derives from `THEMES` so a new theme can't silently escape the grading. Plus the primitives: `parseColor` (`#rgb`/`#rrggbb`/bare hex/`rgb()`), `relativeLuminance`, `contrastRatio`, `blend`, `chipBackdrop`, `rgbToHsl`/`hslToRgb`, `ensureReadable`. Pure — the caller passes `theme.mode`, nothing here reads the DOM. | +| `chipContrast.js` | Contrast-safe chip colors for data-supplied colors the app did not pick — forge issue-label hexes (`apps/tabs/IssuesTab.jsx`), Google Calendar subcalendar colors (`calendar/calendarUtils.js`), and fixed category palettes rendered on theme-following surfaces (`brain/tabs/BrainGraph.jsx`, `cos/TerminalCoSPanel.jsx`). `chipColors(color, mode)` → `{ color, borderColor, backgroundColor }` for an inline `style` (null when the color is missing/unparseable, so the caller falls back to a neutral chip): the wash keeps the label's own color, while the text/border keep its HUE and move only its LIGHTNESS, by the smallest step that clears WCAG AA against the chip's own tint on the ACTIVE theme mode. Memoized per (color, mode). Graded against `SURFACES` — the worst-case surface per mode (darkest `day`, lightest `night`), which the test re-derives from `THEMES` so a new theme can't silently escape the grading. Plus the primitives: `parseColor` (`#rgb`/`#rrggbb`/bare hex/`rgb()`), `relativeLuminance`, `contrastRatio`, `blend`, `chipBackdrop`, `rgbToHsl`/`hslToRgb`, `ensureReadable`. Pure — the caller passes `theme.mode`, nothing here reads the DOM. | | `clientErrorReporter.js` | `reportClientError({ type, error?, message?, ... })` — POSTs window.onerror + unhandledrejection events to `/api/client-errors` with throttle + dedup, dropping browser-extension errors (see `extensionErrors.js`) before either gate. Wired from `main.jsx`; never call directly from React components. | | `clinicianReport.js` | Pure builders for the MeatSpace clinician-export view (`/meatspace/export`). `buildClinicianReport({ tests, config })` → structured report model (blood panels grouped by category with reference ranges + out-of-range flags, plus a lifestyle summary); `reportToMarkdown(report)` → copy-paste markdown. Reuses the Blood tab's `REFERENCE_RANGES` / `getBloodValueStatus` so printed flags match the UI. Also exports `buildBloodTestModel`, `buildLifestyleModel`, `getCategoryForKey`, `formatRange`. | | `quotaBurnPatch.js` | `mergeQuotaBurnPatch(base, patch)` — mirrors the server's quota-burn config merge (top-level + per-family keys merge, a family's `jobs` array replaces) so the Quota Burn page can apply an edit optimistically and accumulate debounced edits into one PUT body. `applyQuotaBurnPreset(job, preset)` / `jobFromPreset(preset, { id, appId })` — copy a catalog prompt preset into a job, preserving the user's own step name and app choice. `quotaBurnJobIsSpent(job, ranAt)` — whether a `run once` step has had its one dispatch, gated on the optimistic config's own `runOnce` so a just-ticked checkbox reads as spent before the save round-trips (the client mirror of the server's `jobIsSpent`). `UNLIMITED_DISPATCHES` / `isUnlimitedDispatchCap(cap)` / `dispatchCapInput(value)` — the -1 "no dispatch cap" sentinel (the default), mirrored from the server. |