From 1639f39207f29d7bb196c50f7d7eb3af8c21ad12 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 5 May 2026 18:13:11 -0400 Subject: [PATCH 1/5] fix(graph-3d): family-distinct node colors + larger course roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three.js's Color.setStyle silently rendered every node black because shadeFor returned space-separated `hsl(120 50% 50%)` strings, which the parser only accepts comma-separated. Switch shadeFor to hex output, tighten hue variance ±25 → ±10 so each course family reads as one cohesive color band, and add a deterministic palette fallback (paletteFor in lib/data.ts) so backends without course_color still get distinct hues per course_id. Course-root nodes pinned to a larger fixed size (nodeVal=22) so the family center anchors the eye. Replace var(--c-sage) screen fallbacks with resolved hex — CSS custom properties don't survive WebGL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/KnowledgeGraph.test.tsx | 26 +++++++--- frontend/src/components/KnowledgeGraph.tsx | 49 ++++++++++++++++--- frontend/src/components/screens/Dashboard.tsx | 7 ++- frontend/src/components/screens/Learn.tsx | 7 ++- frontend/src/components/screens/Tree.tsx | 11 ++++- frontend/src/lib/data.ts | 16 ++++++ 6 files changed, 96 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/KnowledgeGraph.test.tsx b/frontend/src/components/KnowledgeGraph.test.tsx index 0e437d5..09391bc 100644 --- a/frontend/src/components/KnowledgeGraph.test.tsx +++ b/frontend/src/components/KnowledgeGraph.test.tsx @@ -5,9 +5,13 @@ * and the `react-force-graph-3d` library: * 1. Renders without crashing on empty data. * 2. `graphData` memo produces the {nodes, links:{source,target,strength}} shape. - * 3. `nodeColor` returns "#ffffff" for the highlighted node and an - * `hsl(...)` shade for everything else. - * 4. `nodeVal` scales 4..10 with `mastery_score`. + * 3. `nodeColor` returns the brand --accent for the highlighted node + * and a deterministic hex shade for everything else (hex, not + * `hsl(...)`, because Three.js's Color parser only accepts the + * comma-separated HSL form and silently renders space-separated + * HSL as black). + * 4. `nodeVal` scales 4..10 with `mastery_score`, and course-root + * nodes (`is_subject_root: true`) render at a fixed larger size. * 5. `onNodeClick` whitelists the original GraphNode by id so * library-injected fields (x/y/z, vx/vy/vz, fx/fy/fz, * __threeObj, ...) never leak to callers. @@ -149,7 +153,7 @@ describe("KnowledgeGraph (3D) — adapter behavior", () => { expect(graphData.nodes[0]).not.toBe(nodes[0]); }); - it("nodeColor returns the brand accent for the highlighted id and an hsl() shade otherwise", () => { + it("nodeColor returns the brand accent for the highlighted id and a hex shade otherwise", () => { render( { // against the cream light theme; the accent pops on both themes. expect(nodeColor({ id: "abc", color: "#88aa55" })).toBe("#8a9a5b"); - // Non-highlight branch: deterministic hsl(...) string from shadeFor. + // Non-highlight branch: deterministic 7-char hex from shadeFor. + // Hex (not hsl) because Three.js parses hex reliably; the modern + // space-separated `hsl(120 50% 50%)` syntax silently renders black. const other = nodeColor({ id: "xyz", color: "#88aa55" }); - expect(other.startsWith("hsl(")).toBe(true); + expect(other).toMatch(/^#[0-9a-f]{6}$/); }); - it("nodeVal scales 4..10 with mastery_score (0 -> 4, 1 -> 10)", () => { + it("nodeVal scales 4..10 with mastery_score and pins course-root nodes larger", () => { render(); expect(lastProps).not.toBeNull(); const nodeVal = lastProps!.nodeVal as (n: object) => number; + // Concept nodes scale linearly with mastery. expect(nodeVal({ mastery_score: 0 })).toBe(4); expect(nodeVal({ mastery_score: 1 })).toBe(10); + + // Course-root nodes anchor the family — fixed larger size that + // dominates any concept node regardless of mastery. + expect(nodeVal({ is_subject_root: true, mastery_score: 0 })).toBe(22); + expect(nodeVal({ is_subject_root: true, mastery_score: 1 })).toBe(22); }); it("onNodeClick whitelists the original GraphNode by id so lib-injected fields never leak", () => { diff --git a/frontend/src/components/KnowledgeGraph.tsx b/frontend/src/components/KnowledgeGraph.tsx index 439694b..9f8739e 100644 --- a/frontend/src/components/KnowledgeGraph.tsx +++ b/frontend/src/components/KnowledgeGraph.tsx @@ -87,17 +87,51 @@ function hexToHsl(hex: string): { h: number; s: number; l: number } | null { return { h, s: s * 100, l: l * 100 }; } +function hslToHex(h: number, s: number, l: number): string { + const sN = s / 100; + const lN = l / 100; + const c = (1 - Math.abs(2 * lN - 1)) * sN; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lN - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const to = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${to(r)}${to(g)}${to(b)}`; +} + +// Brand sage — used as a final fallback when the input color isn't a +// parseable hex (e.g. callers pass through a CSS custom property like +// `var(--c-sage)`, which Three.js can't resolve). +const FALLBACK_HEX = "#8a9a5b"; + function shadeFor(baseHex: string, nodeId: string): string { - const hsl = hexToHsl(baseHex); - if (!hsl) return baseHex; + const hsl = hexToHsl(baseHex) ?? hexToHsl(FALLBACK_HEX)!; const seed = hashId(nodeId); - const dh = (seed % 51) - 25; + // Hue variance is intentionally narrow (±10°) so each course family + // reads as a single color band — children clearly belong to their + // parent course rather than drifting into a sibling's hue. Within- + // family distinction comes from saturation/lightness instead. + const dh = (seed % 21) - 10; const ds = ((seed >> 5) % 17) - 8; const dl = ((seed >> 10) % 25) - 12; const h = (hsl.h + dh + 360) % 360; const s = Math.max(20, Math.min(85, hsl.s + ds)); const l = Math.max(28, Math.min(62, hsl.l + dl)); - return `hsl(${h.toFixed(0)} ${s.toFixed(0)}% ${l.toFixed(0)}%)`; + // Return hex (#RRGGBB), not `hsl(...)`. Three.js's Color.setStyle + // only accepts comma-separated `hsl(h, s%, l%)`, not the modern + // space-separated form; mismatches silently render black. Hex is + // unambiguous across consumers. + return hslToHex(h, s, l); } // ── Component ────────────────────────────────────────────────────── @@ -207,9 +241,10 @@ export function KnowledgeGraph({ const nodeVal = React.useCallback((raw: object) => { const n = raw as FG3DNode; - // Bigger nodes for higher mastery so the eye lands on what the - // student has built up. Range ~4..10 — keeps small nodes visible - // without dwarfing the rest. + // Course (root) nodes anchor each family — render them noticeably + // larger than concept nodes so the eye lands on the family center + // first. Concept nodes scale 4..10 with mastery_score. + if (n.is_subject_root) return 22; return 4 + (typeof n.mastery_score === "number" ? n.mastery_score : 0) * 6; }, []); diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index 1b354cd..033f4db 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -21,7 +21,7 @@ import { type Assignment, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; +import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; const QUOTES = [ "Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci", @@ -38,7 +38,10 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { id: n.id, name: n.concept_name, subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", + color: + n.course_color || + course?.color || + paletteFor(n.course_id || course?.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index ab753b8..fdeb63b 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -33,7 +33,7 @@ import { type EnrolledCourse, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; +import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { const course = courses.find((c) => c.course_name === n.subject); @@ -41,7 +41,10 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { id: n.id, name: n.concept_name, subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", + color: + n.course_color || + course?.color || + paletteFor(n.course_id || course?.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index b22959c..000af52 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -12,7 +12,7 @@ import { getGraph, getCourses, getSessions, deleteGraphNode, type EnrolledCourse import { useToast } from "../ToastProvider"; import { useConfirm } from "@/lib/useConfirm"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import type { GraphNode, GraphEdge } from "@/lib/data"; +import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; type Tier = "all" | "mastered" | "learning" | "struggling" | "unexplored"; @@ -29,7 +29,14 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { id: n.id, name: n.concept_name, subject: n.subject, - color: n.course_color || course?.color || "var(--c-sage)", + // Resolved hex (not CSS custom property): the 3D KnowledgeGraph + // feeds this into Three.js which can't resolve `var(--…)`. + // Final fallback hashes course_id → distinct palette color so each + // course family reads as its own hue even without backend colors. + color: + n.course_color || + course?.color || + paletteFor(n.course_id || course?.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index 495014e..c15776f 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -5,6 +5,22 @@ export type Course = { color: string; }; +// Brand-aligned course palette. Used as a deterministic fallback when +// the backend / enrolled-course record doesn't supply a per-course +// color, so each course family still reads as its own hue rather than +// every concept collapsing onto one fallback color. +const COURSE_PALETTE = [ + "#8a9a5b", "#3e6f8a", "#7b4b99", "#b4562c", + "#3f8a7c", "#c89c4a", "#a06b8e", "#6b8a3e", +]; + +export function paletteFor(seed: string | null | undefined): string { + if (!seed) return COURSE_PALETTE[0]; + let h = 0; + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0; + return COURSE_PALETTE[Math.abs(h) % COURSE_PALETTE.length]; +} + export type GraphNode = { id: string; name: string; From 0373889468caf2360e25cb10f1773c6319ca2ba8 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 5 May 2026 18:24:11 -0400 Subject: [PATCH 2/5] fix(graph-3d): stabilize family color seed + drop fallback assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from PR #95 review: 1. The palette seed in apiToGraphNode preferred n.course_id over course?.course_id. When some nodes in a family carry course_id and others don't (falling through to subject), they hashed to different palette colors and the family visually split. Prefer the course-record id so all nodes in a family seed identically. 2. shadeFor used `hexToHsl(FALLBACK)!` — safe today only because the FALLBACK constant happens to parse. Replaced with a precomputed FALLBACK_HSL literal (`{h: 75, s: 26, l: 48}` = #8a9a5b) so the call site can never silently break. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/KnowledgeGraph.tsx | 12 +++++++----- frontend/src/components/screens/Dashboard.tsx | 2 +- frontend/src/components/screens/Learn.tsx | 2 +- frontend/src/components/screens/Tree.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/KnowledgeGraph.tsx b/frontend/src/components/KnowledgeGraph.tsx index 9f8739e..9126e76 100644 --- a/frontend/src/components/KnowledgeGraph.tsx +++ b/frontend/src/components/KnowledgeGraph.tsx @@ -109,13 +109,15 @@ function hslToHex(h: number, s: number, l: number): string { return `#${to(r)}${to(g)}${to(b)}`; } -// Brand sage — used as a final fallback when the input color isn't a -// parseable hex (e.g. callers pass through a CSS custom property like -// `var(--c-sage)`, which Three.js can't resolve). -const FALLBACK_HEX = "#8a9a5b"; +// Brand sage in HSL — used as a final fallback when the input color +// isn't parseable hex (e.g. callers pass through `var(--c-sage)`, +// which Three.js can't resolve). Precomputed so the call site doesn't +// need a non-null assertion that would silently break if anyone +// changed FALLBACK to an unparseable value. +const FALLBACK_HSL = { h: 75, s: 26, l: 48 } as const; function shadeFor(baseHex: string, nodeId: string): string { - const hsl = hexToHsl(baseHex) ?? hexToHsl(FALLBACK_HEX)!; + const hsl = hexToHsl(baseHex) ?? FALLBACK_HSL; const seed = hashId(nodeId); // Hue variance is intentionally narrow (±10°) so each course family // reads as a single color band — children clearly belong to their diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index 033f4db..4314cd4 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -41,7 +41,7 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { color: n.course_color || course?.color || - paletteFor(n.course_id || course?.course_id || n.subject), + paletteFor(course?.course_id || n.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index fdeb63b..1a9d03c 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -44,7 +44,7 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { color: n.course_color || course?.color || - paletteFor(n.course_id || course?.course_id || n.subject), + paletteFor(course?.course_id || n.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index 000af52..c0d2c61 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -31,12 +31,13 @@ function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { subject: n.subject, // Resolved hex (not CSS custom property): the 3D KnowledgeGraph // feeds this into Three.js which can't resolve `var(--…)`. - // Final fallback hashes course_id → distinct palette color so each - // course family reads as its own hue even without backend colors. + // Seed prefers the course-record id so every node in the same + // family hashes to the same palette color, even if some nodes + // arrive without `n.course_id` set. color: n.course_color || course?.color || - paletteFor(n.course_id || course?.course_id || n.subject), + paletteFor(course?.course_id || n.course_id || n.subject), is_subject_root: n.is_subject_root, mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, mastery_score: n.mastery_score, From 44b6176c7ee3e2d956d220ea62da33ff4e42add7 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 5 May 2026 21:55:20 -0400 Subject: [PATCH 3/5] refactor(graph): extract hashSeed, hoist apiToGraphNode, add lib tests Three non-blocking follow-ups from PR #95 review: 1. Deduplicate the DJB2 hash (was reimplemented in lib/data.ts and KnowledgeGraph.tsx) into a single `hashSeed` export. 2. Hoist the triplicated apiToGraphNode adapter from Tree/Learn/ Dashboard into lib/data.ts. 3. Add unit tests for paletteFor + hashSeed. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/KnowledgeGraph.tsx | 10 +---- frontend/src/components/screens/Dashboard.tsx | 20 +-------- frontend/src/components/screens/Learn.tsx | 20 +-------- frontend/src/components/screens/Tree.tsx | 25 +---------- frontend/src/lib/data.test.ts | 37 ++++++++++++++++ frontend/src/lib/data.ts | 43 +++++++++++++++++-- 6 files changed, 82 insertions(+), 73 deletions(-) create mode 100644 frontend/src/lib/data.test.ts diff --git a/frontend/src/components/KnowledgeGraph.tsx b/frontend/src/components/KnowledgeGraph.tsx index 9126e76..0804c8a 100644 --- a/frontend/src/components/KnowledgeGraph.tsx +++ b/frontend/src/components/KnowledgeGraph.tsx @@ -25,7 +25,7 @@ import React from "react"; import dynamic from "next/dynamic"; -import type { GraphEdge, GraphNode } from "@/lib/data"; +import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; // `react-force-graph-3d`'s default export touches `document` at // module evaluation, so it can't be SSR'd. ssr: false ensures the @@ -60,12 +60,6 @@ type Props = { // tone, and produces identical output across pages because it depends // only on the stable inputs (no per-screen overrides). -function hashId(id: string): number { - let h = 0; - for (let i = 0; i < id.length; i++) h = ((h << 5) - h + id.charCodeAt(i)) | 0; - return Math.abs(h); -} - function hexToHsl(hex: string): { h: number; s: number; l: number } | null { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return null; @@ -118,7 +112,7 @@ const FALLBACK_HSL = { h: 75, s: 26, l: 48 } as const; function shadeFor(baseHex: string, nodeId: string): string { const hsl = hexToHsl(baseHex) ?? FALLBACK_HSL; - const seed = hashId(nodeId); + const seed = hashSeed(nodeId); // Hue variance is intentionally narrow (±10°) so each course family // reads as a single color band — children clearly belong to their // parent course rather than drifting into a sibling's hue. Within- diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index 4314cd4..d649a2c 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -21,7 +21,7 @@ import { type Assignment, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; const QUOTES = [ "Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci", @@ -32,24 +32,6 @@ const QUOTES = [ "Tell me and I forget. Teach me and I remember. Involve me and I learn. — Franklin", ]; -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - color: - n.course_color || - course?.color || - paletteFor(course?.course_id || n.course_id || n.subject), - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} - function apiToGraphEdge(e: ApiEdge): GraphEdge { return { source: e.source as string, target: e.target as string, strength: e.strength }; } diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index 1a9d03c..2d671d4 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -33,25 +33,7 @@ import { type EnrolledCourse, } from "@/lib/api"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; - -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - color: - n.course_color || - course?.color || - paletteFor(course?.course_id || n.course_id || n.subject), - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; function apiToGraphEdge(e: ApiEdge): GraphEdge { return { source: e.source as string, target: e.target as string, strength: e.strength }; diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index c0d2c61..b935737 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -12,7 +12,7 @@ import { getGraph, getCourses, getSessions, deleteGraphNode, type EnrolledCourse import { useToast } from "../ToastProvider"; import { useConfirm } from "@/lib/useConfirm"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; -import { paletteFor, type GraphNode, type GraphEdge } from "@/lib/data"; +import { apiToGraphNode, type GraphNode, type GraphEdge } from "@/lib/data"; type Tier = "all" | "mastered" | "learning" | "struggling" | "unexplored"; @@ -23,29 +23,6 @@ const TIER_META: Record, { label: string; color: string }> unexplored: { label: "Unexplored", color: "#9a9a9a" }, }; -function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { - const course = courses.find((c) => c.course_name === n.subject); - return { - id: n.id, - name: n.concept_name, - subject: n.subject, - // Resolved hex (not CSS custom property): the 3D KnowledgeGraph - // feeds this into Three.js which can't resolve `var(--…)`. - // Seed prefers the course-record id so every node in the same - // family hashes to the same palette color, even if some nodes - // arrive without `n.course_id` set. - color: - n.course_color || - course?.color || - paletteFor(course?.course_id || n.course_id || n.subject), - is_subject_root: n.is_subject_root, - mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, - mastery_score: n.mastery_score, - course_id: n.course_id || course?.course_id || "", - last_studied_at: n.last_studied_at || undefined, - }; -} - export function Tree() { const router = useRouter(); const search = useSearchParams(); diff --git a/frontend/src/lib/data.test.ts b/frontend/src/lib/data.test.ts new file mode 100644 index 0000000..fe2d83c --- /dev/null +++ b/frontend/src/lib/data.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { hashSeed, paletteFor } from "./data"; + +// First entry of COURSE_PALETTE in data.ts. Asserted directly so the +// palette stays internal to data.ts. +const PALETTE_FIRST = "#8a9a5b"; + +describe("paletteFor", () => { + it("returns the first palette entry for empty / nullish seeds", () => { + expect(paletteFor(null)).toBe(PALETTE_FIRST); + expect(paletteFor(undefined)).toBe(PALETTE_FIRST); + expect(paletteFor("")).toBe(PALETTE_FIRST); + }); + + it("is deterministic for a given seed", () => { + expect(paletteFor("course-42")).toBe(paletteFor("course-42")); + }); + + it("produces multiple distinct outputs across varied seeds", () => { + const colors = new Set(["alpha", "beta", "gamma", "delta"].map(paletteFor)); + expect(colors.size).toBeGreaterThanOrEqual(2); + }); +}); + +describe("hashSeed", () => { + it("is deterministic", () => { + expect(hashSeed("hello")).toBe(hashSeed("hello")); + }); + + it("returns a non-negative integer for typical inputs", () => { + for (const s of ["", "x", "concept-1", "a longer string with spaces"]) { + const h = hashSeed(s); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index c15776f..c33ccb2 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -1,3 +1,6 @@ +import type { GraphNode as ApiNode } from "@/lib/types"; +import type { EnrolledCourse } from "@/lib/api"; + export type Course = { id: string; course_code: string; @@ -14,11 +17,19 @@ const COURSE_PALETTE = [ "#3f8a7c", "#c89c4a", "#a06b8e", "#6b8a3e", ]; +// DJB2-ish string hash → non-negative integer. Shared by `paletteFor` +// (palette index seeding) and `KnowledgeGraph`'s `shadeFor` (per-node +// HSL jitter seeding). Keep the body identical across consumers so the +// same input always maps to the same downstream color. +export function hashSeed(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return Math.abs(h); +} + export function paletteFor(seed: string | null | undefined): string { if (!seed) return COURSE_PALETTE[0]; - let h = 0; - for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0; - return COURSE_PALETTE[Math.abs(h) % COURSE_PALETTE.length]; + return COURSE_PALETTE[hashSeed(seed) % COURSE_PALETTE.length]; } export type GraphNode = { @@ -39,6 +50,32 @@ export type GraphEdge = { strength: number; }; +// Adapter from the backend `ApiNode` shape to the frontend `GraphNode` +// shape consumed by `KnowledgeGraph`. Hoisted here from Tree/Learn/ +// Dashboard so the three screens share a single source of truth. +export function apiToGraphNode(n: ApiNode, courses: EnrolledCourse[]): GraphNode { + const course = courses.find((c) => c.course_name === n.subject); + return { + id: n.id, + name: n.concept_name, + subject: n.subject, + // Resolved hex (not CSS custom property): the 3D KnowledgeGraph + // feeds this into Three.js which can't resolve `var(--…)`. + // Seed prefers the course-record id so every node in the same + // family hashes to the same palette color, even if some nodes + // arrive without `n.course_id` set. + color: + n.course_color || + course?.color || + paletteFor(course?.course_id || n.course_id || n.subject), + is_subject_root: n.is_subject_root, + mastery_tier: n.mastery_tier === "subject_root" ? "mastered" : n.mastery_tier, + mastery_score: n.mastery_score, + course_id: n.course_id || course?.course_id || "", + last_studied_at: n.last_studied_at || undefined, + }; +} + export type Assignment = { id: string; title: string; From 4c2329e61f1d77805873309e5eb279ca576208c4 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 5 May 2026 21:57:34 -0400 Subject: [PATCH 4/5] fix(graph): darken palette sage + ochre to pass WCAG AA 3:1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG audit against the cream `--bg` (#faf8f3) flagged two palette entries below the 3:1 non-text-UI threshold: - sage #8a9a5b → 2.89:1 → #7a874f (~3.5:1) - ochre #c89c4a → 2.38:1 → #a87d2e (~3.6:1) Both nudged darker along the same hue band so node circles stay legible on the light theme. Brand --accent (#8a9a5b) is unchanged elsewhere — this palette is only the backend-color fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/data.test.ts | 5 +++-- frontend/src/lib/data.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/data.test.ts b/frontend/src/lib/data.test.ts index fe2d83c..245495c 100644 --- a/frontend/src/lib/data.test.ts +++ b/frontend/src/lib/data.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { hashSeed, paletteFor } from "./data"; // First entry of COURSE_PALETTE in data.ts. Asserted directly so the -// palette stays internal to data.ts. -const PALETTE_FIRST = "#8a9a5b"; +// palette stays internal to data.ts. (Darkened sage — chosen to clear +// WCAG AA 3:1 contrast against the cream `--bg`.) +const PALETTE_FIRST = "#7a874f"; describe("paletteFor", () => { it("returns the first palette entry for empty / nullish seeds", () => { diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index c33ccb2..a1ea94a 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -12,9 +12,16 @@ export type Course = { // the backend / enrolled-course record doesn't supply a per-course // color, so each course family still reads as its own hue rather than // every concept collapsing onto one fallback color. +// +// Each entry hits ≥3:1 contrast against the cream `--bg` (#faf8f3) — +// WCAG AA threshold for non-text UI elements. Sage and ochre were +// nudged darker (from #8a9a5b → #7a874f and #c89c4a → #a87d2e) so the +// nodes stay legible on the light theme. Brand --accent (#8a9a5b) +// remains unchanged elsewhere; this palette is only the backend-color +// fallback. const COURSE_PALETTE = [ - "#8a9a5b", "#3e6f8a", "#7b4b99", "#b4562c", - "#3f8a7c", "#c89c4a", "#a06b8e", "#6b8a3e", + "#7a874f", "#3e6f8a", "#7b4b99", "#b4562c", + "#3f8a7c", "#a87d2e", "#a06b8e", "#6b8a3e", ]; // DJB2-ish string hash → non-negative integer. Shared by `paletteFor` From e177171c9da7b65a73982aacc340998916595002 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 5 May 2026 22:11:29 -0400 Subject: [PATCH 5/5] test(graph): cover apiToGraphNode color-resolution + seed-priority fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks down the round-2 fix: when n.course_id and course.course_id disagree, the palette seed must come from the course record so every node in the same family hashes to the same color. Also pins the two simpler fallback branches and the subject_root → mastered tier remap. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/data.test.ts | 75 ++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/data.test.ts b/frontend/src/lib/data.test.ts index 245495c..94330dc 100644 --- a/frontend/src/lib/data.test.ts +++ b/frontend/src/lib/data.test.ts @@ -1,5 +1,40 @@ import { describe, expect, it } from "vitest"; -import { hashSeed, paletteFor } from "./data"; +import { apiToGraphNode, hashSeed, paletteFor } from "./data"; +import type { GraphNode as ApiNode } from "./types"; +import type { EnrolledCourse } from "./api"; + +function makeApiNode(over: Partial = {}): ApiNode { + return { + id: "n1", + concept_name: "Eigenvalues", + mastery_score: 0.5, + mastery_tier: "learning", + times_studied: 3, + last_studied_at: null, + subject: "Linear Algebra", + course_id: "c1", + course_color: null, + color: null, + is_subject_root: false, + ...over, + }; +} + +function makeCourse(over: Partial = {}): EnrolledCourse { + return { + enrollment_id: "e1", + course_id: "c1", + course_code: "MATH 242", + course_name: "Linear Algebra", + school: "BU", + department: "MATH", + color: null, + nickname: null, + node_count: 8, + enrolled_at: "2026-01-01", + ...over, + }; +} // First entry of COURSE_PALETTE in data.ts. Asserted directly so the // palette stays internal to data.ts. (Darkened sage — chosen to clear @@ -23,6 +58,44 @@ describe("paletteFor", () => { }); }); +describe("apiToGraphNode color resolution", () => { + it("prefers n.course_color over everything else", () => { + const n = makeApiNode({ course_color: "#abcdef" }); + const course = makeCourse({ color: "#fedcba" }); + expect(apiToGraphNode(n, [course]).color).toBe("#abcdef"); + }); + + it("falls back to course?.color when course_color is missing", () => { + const n = makeApiNode({ course_color: null }); + const course = makeCourse({ color: "#123456" }); + expect(apiToGraphNode(n, [course]).color).toBe("#123456"); + }); + + it("falls back to paletteFor when both course_color and course.color are missing", () => { + const n = makeApiNode({ course_color: null, course_id: "c1" }); + const course = makeCourse({ color: null, course_id: "c1" }); + expect(apiToGraphNode(n, [course]).color).toBe(paletteFor("c1")); + }); + + it("seeds palette from course.course_id, not n.course_id (round-2 fix)", () => { + // Round 2 fixed a bug where two nodes in the same family could + // hash to different palette colors if some carried n.course_id + // and others fell through to subject. The hoisted adapter must + // prefer the course-record id so all family members agree. + const n = makeApiNode({ + course_color: null, + course_id: "different-from-course", + }); + const course = makeCourse({ color: null, course_id: "stable-family-id" }); + expect(apiToGraphNode(n, [course]).color).toBe(paletteFor("stable-family-id")); + }); + + it("remaps subject_root tier to mastered", () => { + const n = makeApiNode({ mastery_tier: "subject_root", is_subject_root: true }); + expect(apiToGraphNode(n, []).mastery_tier).toBe("mastered"); + }); +}); + describe("hashSeed", () => { it("is deterministic", () => { expect(hashSeed("hello")).toBe(hashSeed("hello"));