Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions client/src/components/brain/tabs/BrainGraph.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -738,7 +756,7 @@ export default function BrainGraph() {
<div className="flex items-center gap-2 mb-1">
<span
className="px-1.5 py-0.5 text-[10px] rounded-full border"
style={{ borderColor: BRAIN_TYPE_HEX[hoveredNode.brainType], color: BRAIN_TYPE_HEX[hoveredNode.brainType] }}
style={brainTypeChipStyle(hoveredNode.brainType, theme?.mode)}
>
{DESTINATIONS[hoveredNode.brainType]?.label || hoveredNode.brainType}
</span>
Expand All @@ -762,7 +780,7 @@ export default function BrainGraph() {
<div className="flex items-center gap-2 mb-2">
<span
className="px-2 py-1 text-xs rounded-full border"
style={{ borderColor: BRAIN_TYPE_HEX[selectedNode.brainType], color: BRAIN_TYPE_HEX[selectedNode.brainType] }}
style={brainTypeChipStyle(selectedNode.brainType, theme?.mode)}
>
{DESTINATIONS[selectedNode.brainType]?.label || selectedNode.brainType}
</span>
Expand Down
38 changes: 38 additions & 0 deletions client/src/components/brain/tabs/BrainGraph.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 15 additions & 2 deletions client/src/components/calendar/ChronotypeOverlay.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,16 +11,27 @@ 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);
}, []);

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;
Expand All @@ -42,7 +55,7 @@ export default function ChronotypeOverlay({ startHour, pxPerHour }) {
/>
<span
className="absolute right-1 -top-4 text-[10px] font-medium px-1.5 py-0.5 rounded"
style={{ color: zone.color, backgroundColor: `${zone.color}20` }}
style={{ color: labelColor(zone.color), backgroundColor: `${zone.color}20` }}
>
{zone.label}
</span>
Expand All @@ -67,7 +80,7 @@ export default function ChronotypeOverlay({ startHour, pxPerHour }) {
>
<span
className="absolute right-1 top-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded"
style={{ color: zone.color, backgroundColor: `${zone.color}30`, opacity: 1 }}
style={{ color: labelColor(zone.color), backgroundColor: `${zone.color}30`, opacity: 1 }}
>
{zone.label}
</span>
Expand Down
17 changes: 9 additions & 8 deletions client/src/components/calendar/DayView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}
</button>
Expand Down Expand Up @@ -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)
}}
>
<div className="text-xs leading-tight font-medium text-white truncate">{event.title}</div>
{/* 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. */}
<div className="text-xs leading-tight font-medium truncate">{event.title}</div>
{height > 32 && event.location && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 truncate">
<MapPin size={10} /> {event.location}
Expand Down
9 changes: 4 additions & 5 deletions client/src/components/calendar/MonthView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 && (
<span className="text-gray-500 mr-1">
Expand Down
17 changes: 9 additions & 8 deletions client/src/components/calendar/WeekView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
</button>
Expand Down Expand Up @@ -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)
}}
>
<div className="text-[10px] leading-tight font-medium text-white truncate">{event.title}</div>
{/* 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. */}
<div className="text-[10px] leading-tight font-medium truncate">{event.title}</div>
</button>
);
})}
Expand Down
Loading
Loading