diff --git a/.gitignore b/.gitignore
index d27d551..66a8c3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -206,3 +206,4 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
+.impeccable/
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..b51db49
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,261 @@
+---
+name: Ship of Theseus
+description: Visualizing codebase entropy through the Ship of Theseus paradox
+colors:
+ abyss: "oklch(2.5% 0.005 260)"
+ surface: "oklch(7% 0.008 260)"
+ surface-raised: "oklch(10% 0.01 260)"
+ seafoam: "oklch(68% 0.14 195)"
+ nebula: "oklch(52% 0.22 285)"
+ ember: "oklch(72% 0.16 65)"
+ ice: "oklch(96% 0.003 255)"
+ mist: "oklch(65% 0.015 255)"
+ frost: "oklch(45% 0.015 255)"
+ border: "oklch(20% 0.01 260 / 0.4)"
+ border-hover: "oklch(35% 0.02 260 / 0.5)"
+ error-bg: "oklch(20% 0.08 25 / 0.3)"
+ error-text: "oklch(70% 0.12 25)"
+typography:
+ display:
+ fontFamily: "'Playfair Display', Georgia, serif"
+ fontSize: "clamp(2.5rem, 6vw, 4rem)"
+ fontWeight: 700
+ lineHeight: 1.1
+ letterSpacing: "-0.02em"
+ title:
+ fontFamily: "'Playfair Display', Georgia, serif"
+ fontSize: "clamp(1.5rem, 3vw, 2.2rem)"
+ fontWeight: 600
+ lineHeight: 1.2
+ letterSpacing: "normal"
+ body:
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace"
+ fontSize: "0.9rem"
+ fontWeight: 400
+ lineHeight: 1.6
+ label:
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace"
+ fontSize: "0.7rem"
+ fontWeight: 500
+ lineHeight: 1.4
+ letterSpacing: "0.1em"
+ textTransform: "uppercase"
+ mono:
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace"
+ fontSize: "0.85rem"
+ fontWeight: 400
+ lineHeight: 1.5
+rounded:
+ sm: "0.5rem"
+ md: "1rem"
+ lg: "1.5rem"
+ xl: "2rem"
+ full: "9999px"
+spacing:
+ xs: "0.5rem"
+ sm: "1rem"
+ md: "1.5rem"
+ lg: "2rem"
+ xl: "3rem"
+ xxl: "4rem"
+components:
+ button-active:
+ backgroundColor: "{colors.seafoam}"
+ textColor: "{colors.abyss}"
+ rounded: "{rounded.sm}"
+ padding: "0.4rem 1rem"
+ button-default:
+ backgroundColor: "transparent"
+ textColor: "{colors.mist}"
+ rounded: "{rounded.sm}"
+ padding: "0.4rem 1rem"
+ insight-card:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.ice}"
+ rounded: "{rounded.xl}"
+ padding: "2.5rem"
+ glass-panel:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.ice}"
+ rounded: "{rounded.xl}"
+ padding: "2.5rem"
+ badge:
+ backgroundColor: "oklch(100% 0 0 / 0.03)"
+ textColor: "{colors.mist}"
+ rounded: "{rounded.full}"
+ padding: "0.4rem 0.9rem"
+ selector-pill:
+ backgroundColor: "oklch(100% 0 0 / 0.05)"
+ rounded: "{rounded.full}"
+ padding: "0.5rem"
+ fossil-card:
+ backgroundColor: "oklch(100% 0 0 / 0.02)"
+ textColor: "{colors.ice}"
+ rounded: "{rounded.lg}"
+ padding: "1.5rem 2rem"
+ pill-small:
+ backgroundColor: "oklch(100% 0 0 / 0.05)"
+ rounded: "{rounded.md}"
+ padding: "0.25rem"
+ code-block:
+ backgroundColor: "oklch(0% 0 0 / 0.4)"
+ rounded: "{rounded.md}"
+ padding: "0.6rem 1rem"
+ tooltip:
+ backgroundColor: "oklch(2.5% 0.005 260 / 0.98)"
+ rounded: "{rounded.md}"
+ padding: "1.75rem"
+---
+
+# Design System: Ship of Theseus
+
+## 1. Overview
+
+**Creative North Star: "The Observatory"**
+
+The Observatory is a place of patient observation. You stand at the instrument, peering through the lens at something vast and slow-moving: the evolution of a codebase across years. The interface around you recedes. The data is the view. Every element is designed to keep you in that state of focused attention.
+
+This system is dark not because tools look cool in dark mode, but because the scene demands it: a developer late at night, studying the strata of their repository on a large monitor in a dim room. The dark is planetarium dark, not nightclub dark. Surfaces are distinguished by tonal layering, not blurs or shadows. The palette is restrained by default, with a single seafoam accent providing navigational and interactive signal.
+
+This system explicitly rejects glassmorphism, gradient text, neon-on-black cyberpunk aesthetics, and generic SaaS dashboard conventions.
+
+### Key Characteristics:
+- Dark tonal surfaces with subtle light steps for hierarchy
+- One accent color (seafoam) for active and interactive elements
+- Serif display for narrative weight, monospace body for technical precision
+- Flat with tonal layering; no shadows, no blur
+- Sharp, responsive state transitions
+- Planetarium atmosphere, not dashboard utility
+
+## 2. Colors
+
+The palette is dark with restrained chroma. Neutrals are tinted subtly toward a cool blue hue (chroma 0.005-0.01). The single accent carries the full interactive burden: active buttons, metric highlights, links.
+
+### Primary
+- **Seafoam** (oklch(68% 0.14 195)): The single accent. Active button states, metric values, philosophy links, help icon hover, year labels. Used sparingly; its rarity is what gives it weight.
+
+### Secondary
+- **Nebula** (oklch(52% 0.22 285)): Secondary signal. The milestone marker star, gradient hints in fossil cards. Rare.
+
+### Tertiary
+- **Ember** (oklch(72% 0.16 65)): Tertiary accent. Commit hash badges and fossil year highlights for warmth.
+
+### Neutral
+- **Abyss** (oklch(2.5% 0.005 260)): Primary background. The full-canvas ground.
+- **Surface** (oklch(7% 0.008 260)): Card and panel background. The raised layer.
+- **Surface-raised** (oklch(10% 0.01 260)): Hovered or interactive container state.
+- **Ice** (oklch(96% 0.003 255)): Primary text color. High-emphasis content.
+- **Mist** (oklch(65% 0.015 255)): Secondary text, labels, muted content.
+- **Frost** (oklch(45% 0.015 255)): Tertiary text, placeholder, disabled.
+- **Border** (oklch(20% 0.01 260 / 0.4)): Default dividers and container borders.
+- **Border-hover** (oklch(35% 0.02 260 / 0.5)): Hovered container borders.
+
+### Named Rules
+**The Observatory Dimming Rule.** Chroma decreases as lightness approaches 0 or 100. High-chroma colors at extreme lightness values look garish on a dark canvas. Seafoam at oklch(68% 0.14) is the brightest and most saturated point; nothing exceeds it.
+
+**The One Accent Rule.** Seafoam is the only saturated accent. Purple and orange are used for data differentiation only (chart layers, fossil tags), never for navigation or interactive signal.
+
+## 3. Typography
+
+**Display Font:** Playfair Display (Georgia, serif fallback)
+**Body Font:** JetBrains Mono (SF Mono, Cascadia Code fallback)
+**Label/Mono Font:** JetBrains Mono (SF Mono, Cascadia Code fallback)
+
+**Character:** The pairing is deliberate: serif for philosophy and narrative weight, monospace for data and engineering precision. The serif is warm and classical; the mono is technical and sharp. They coexist without compromise, each owning its domain.
+
+### Hierarchy
+- **Display** (Bold 700, clamp(2.5rem, 6vw, 4rem), 1.1): The main title. Hero only. One line maximum. Solid color, never gradient.
+- **Title** (Semibold 600, clamp(1.5rem, 3vw, 2.2rem), 1.2): Section headings, narrative titles, fossil section headings.
+- **Body** (Regular 400, 0.9rem, 1.6): All reading text, tooltip content, card descriptions. Capped at 65-75ch.
+- **Label** (Medium 500, 0.7rem, 1.4, uppercase, 0.1em letter-spacing): Control labels, card titles, legend items, badge text.
+- **Mono** (Regular 400, 0.85rem, 1.5): Code blocks, fossil code content, axis labels, metric values.
+
+## 4. Elevation
+
+This system uses tonal layering, not shadows or blur. Depth is conveyed purely by lightness steps: background (abyss) sits below surface, which sits below surface-raised. There are no box shadows, no backdrop filters. The absence of shadows is deliberate: it keeps the interface quiet and reduces visual noise, letting the data layers in the chart command attention.
+
+### Tonal Layers
+- **Depth 0** (abyss): Full-canvas background.
+- **Depth 1** (surface): Cards, panels, container backgrounds.
+- **Depth 2** (surface-raised): Hovered containers, focused controls, the tooltip backdrop.
+
+### Named Rules
+**The Flat-By-Default Rule.** No surface casts a shadow at rest. The only depth cue is tonal. Hovered elements may shift up one tonal layer or translate upward by 2-4px, but they never grow a shadow.
+
+## 5. Components
+
+### Buttons (Repo Selector, Mode/Scale Toggles)
+- **Shape:** Sharply curved corners (0.5rem).
+- **Default:** Transparent background, mist text color. Blends into the surface.
+- **Active:** Seafoam background, abyss text color, no shadow. The color switch is the signal.
+- **Hover:** Text transitions to ice (from mist). Active state text uses abyss (white on seafoam).
+- **Transition:** 0.2s ease-out on background-color + color.
+
+### Chips (Selector Pills)
+- **Not used as standalone chips.** The selector-pill is a group container (full border-radius, dark surface). Buttons sit inside it as described above.
+
+### Cards / Containers (Glass Panels, Insight Cards, Info Cards)
+- **Corner Style:** Generous curve (2rem).
+- **Background:** Surface tonal layer (oklch(7% 0.008 260)).
+- **Shadow Strategy:** None. Flat by default.
+- **Border:** 1px solid border color. On hover, border transitions to border-hover.
+- **Internal Padding:** 2.5rem (insight card), 1.5rem (info card), 3rem (fossil finder).
+- **Hover:** Container translates up 4-8px with same easing, border color shifts to border-hover.
+
+### Inputs / Fields
+- None in current scope. The chart interaction is pointer-based, not form-driven.
+
+### Navigation
+- Not applicable. Single-page application with no navigation chrome. The repo selector bar serves as navigation.
+
+### Fossil Cards
+- **Corner Style:** Curved corners (1.5rem).
+- **Background:** Slightly transparent (oklch(100% 0 0 / 0.02)).
+- **Border:** 1px solid subtle white (rgba(255,255,255,0.1)). On hover, border shifts toward seafoam.
+- **Hover:** 4px upward translate, seafoam glow via box-shadow (no blur backdrop).
+- **Padding:** 1.5rem top/bottom, 2rem sides.
+
+### Tooltip (Chart Hover)
+- **Background:** Near-opaque abyss (oklch(2.5% 0.005 260 / 0.98)).
+- **Border:** 1px solid border color.
+- **Corner Style:** 1.25rem.
+- **Padding:** 1.75rem.
+- **Width:** Minimum 340px.
+- **Shadow:** Strong diffuse shadow (box-shadow, not backdrop-filter) for separation from the chart.
+
+### Code Block (Fossil Content)
+- **Background:** Near-black (oklch(0% 0 0 / 0.4)).
+- **Border:** 1px solid border color.
+- **Corner Style:** 0.75rem.
+- **Font:** Mono, 0.85rem.
+- **Prefix:** Seafoam `>` character (opacity 0.5).
+
+### Badge (Author Badge)
+- **Shape:** Full pill (9999px).
+- **Background:** Near-white at 3% opacity.
+- **Border:** 1px solid border color.
+- **Text:** Mist, uppercase, 0.6rem, letter-spacing 0.15em.
+- **Hover:** Background intensifies, border lightens, text shifts to ice.
+
+## 6. Do's and Don'ts
+
+### Do:
+- **Do** use tonal layering for depth: abyss, surface, surface-raised. No shadows, no blurs.
+- **Do** use seafoam as the single interactive accent. Its rarity is the source of its signal.
+- **Do** use Playfair Display for narrative, JetBrains Mono for data. Never mix them in the same element.
+- **Do** use solid text colors. The main title must never use gradient text.
+- **Do** use the flat-by-default rule. Surfaces sit flat until hovered.
+- **Do** cap body text at 65-75 characters per line.
+- **Do** use ease-out (quart or quint) for transitions. No bounce, no elastic.
+
+### Don't:
+- **Don't** use glassmorphism. No backdrop-filter: blur on any surface. The planetarium is not a kaleidoscope.
+- **Don't** use gradient text (background-clip: text with gradient). The title is one solid color.
+- **Don't** use side-stripe borders. No border-left or border-right over 1px as decorative accent.
+- **Don't** replicate the hero-metric template (big number, small label, gradient accent).
+- **Don't** create identical card grids with icon + heading + text patterns.
+- **Don't** use cyberpunk or hacker aesthetics. No neon-on-black, no glowing borders, no scanlines.
+- **Don't** use generic AI slop patterns. If it looks like it was templated, redesign it.
+- **Don't** use em dashes. Use commas, colons, or periods.
+- **Don't** use `#000` or `#fff`. Tint every neutral toward cool blue (chroma 0.005-0.01).
+- **Don't** animate CSS layout properties. Use transform and opacity only.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..f988b68
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,46 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+An intersection of software engineers, open-source maintainers, and philosophy-curious developers. They range from engineering leads assessing codebase health to solo developers exploring how their projects evolve over time. Their context is analytical but reflective — they're not just crunching numbers, they're contemplating the nature of software identity.
+
+## Product Purpose
+
+Visualize codebase entropy through the lens of the Ship of Theseus paradox. Every month, the engine snapshots which lines of code survive and which are replaced, rendering a stacked area chart that shows the age composition of a repository over time. Success means giving users a visceral, data-driven understanding of how their codebases are constantly being reborn.
+
+## Brand Personality
+
+Atmospheric, bold, narrative. The tone is confident and thoughtful — a data scientist who also reads philosophy. Not academic, not bro-y. Think observatory dark, not cave dark. The interface should feel like peering through a telescope at codebase evolution, not like monitoring server alerts.
+
+## Anti-references
+
+- Generic AI slop patterns
+- Glassmorphism as a decorative default
+- Cyberpunk / hacker / neon-on-black aesthetics
+- Generic SaaS dashboard conventions (hero-metric template, identical card grids, side-stripe borders)
+- Gradient text
+- Overly playful or childish UI patterns
+
+## Design Principles
+
+1. **Narrative through data** — The visualization tells a story of codebase evolution. Every element should serve that narrative, not distract from it. The chart is the hero; everything else supports it.
+
+2. **Philosophical depth, not decoration** — The Ship of Theseus is an ancient paradox. The interface should feel considered and intentional, not assembled from a template. Every visual choice should have a reason rooted in the concept.
+
+3. **Atmosphere with restraint** — Bold and atmospheric is the goal, but not through glass, gradients, or neon. Use typography, spacing, light, and subtle color to create depth. The dark should feel like a planetarium, not a nightclub.
+
+4. **Precision earns boldness** — Small details (tooltips, transitions, skeleton states) should be meticulously crafted. That precision is what earns the right to be bold with scale, typography, and the narrative voice.
+
+5. **Accessibility is craft** — WCAG AA minimum, prefer AAA. Contrast, focus states, reduced motion, and screen reader support are not checkboxes — they are part of the design. Atmospheric does not mean inaccessible.
+
+## Accessibility & Inclusion
+
+- WCAG AA minimum, prefer AAA where practical
+- Support reduced motion preferences
+- Ensure sufficient color contrast for all data visualization layers
+- Keyboard-navigable controls and chart interaction
+- Screen reader friendly tooltips and data descriptions
diff --git a/app.js b/app.js
index b1f8636..daad81a 100644
--- a/app.js
+++ b/app.js
@@ -24,7 +24,10 @@ class TheseusVisualizer {
this.vizMode = 'chronological'; // 'chronological' | 'identity'
this.yScaleMode = 'linear'; // 'linear' | 'log'
this.fossils = {};
+ this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ this.animDuration = this.reducedMotion ? 0 : 800;
+ this.focusIndex = undefined;
this.init();
}
@@ -50,6 +53,8 @@ class TheseusVisualizer {
}
window.addEventListener('resize', () => this.debouncedRender());
+ this.setupKeyboardShortcuts();
+ this.setupRepoRequest();
}
setupModeToggle() {
@@ -78,6 +83,108 @@ class TheseusVisualizer {
});
}
+ setupKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+
+ const key = e.key;
+
+ if (key >= '1' && key <= '9') {
+ const idx = parseInt(key, 10) - 1;
+ const btns = document.querySelectorAll('.repo-btn');
+ if (idx < btns.length) {
+ btns[idx].click();
+ }
+ return;
+ }
+
+ if (key === 'm' || key === 'M') {
+ const active = document.querySelector('.mode-btn.active');
+ if (active) {
+ const next = active.nextElementSibling || active.previousElementSibling;
+ if (next) next.click();
+ }
+ return;
+ }
+
+ if (key === 's' || key === 'S') {
+ const active = document.querySelector('.scale-btn.active');
+ if (active) {
+ const next = active.nextElementSibling || active.previousElementSibling;
+ if (next) next.click();
+ }
+ return;
+ }
+ });
+ }
+
+ setupRepoRequest() {
+ const form = document.getElementById('repo-request-form');
+ const input = document.getElementById('repo-url');
+ const feedback = document.getElementById('repo-request-feedback');
+ if (!form || !input || !feedback) return;
+
+ function setFeedback(message, type) {
+ feedback.textContent = message;
+ feedback.className = 'repo-request-feedback' + (type ? ' ' + type : '');
+ feedback.classList.toggle('visible', !!message);
+ }
+
+ function validateUrl(raw) {
+ const cleaned = raw
+ .replace(/^https?:\/\//, '')
+ .replace(/^www\./, '')
+ .replace(/\/$/, '')
+ .replace(/^github\.com\//, '');
+ return /^[\w.-]+\/[\w.-]+$/.test(cleaned) ? cleaned : null;
+ }
+
+ input.addEventListener('input', () => {
+ input.classList.remove('invalid');
+ if (feedback.textContent) setFeedback('');
+ });
+
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const raw = input.value.trim();
+ if (!raw) {
+ input.classList.add('invalid');
+ setFeedback('Enter a repository URL or owner/repo name.', 'error');
+ return;
+ }
+
+ const slug = validateUrl(raw);
+ if (!slug) {
+ input.classList.add('invalid');
+ setFeedback('Enter a valid GitHub URL (e.g. owner/repo)', 'error');
+ return;
+ }
+
+ const title = `Repository request: ${slug}`;
+ const body = [
+ `**Repository:** ${raw}`,
+ '',
+ '---',
+ 'Submitted via the Ship of Theseus dashboard.',
+ ].join('\n');
+
+ const url = new URL('https://github.com/Asifdotexe/Theseus/issues/new');
+ url.searchParams.set('title', title);
+ url.searchParams.set('body', body);
+ url.searchParams.set('labels', 'repository request');
+
+ const popup = window.open(url.toString(), '_blank');
+ if (!popup || popup.closed || typeof popup.closed === 'undefined') {
+ setFeedback('Popup was blocked. Allow popups for this site and try again.', 'error');
+ return;
+ }
+
+ setFeedback('Request submitted. I will look into it soon.', 'success');
+ input.value = '';
+ });
+ }
+
renderSelectors() {
this.repoSelector.innerHTML = '';
this.manifest.forEach(repo => {
@@ -167,16 +274,34 @@ class TheseusVisualizer {
const chartHeight = height - this.margin.top - this.margin.bottom;
const svg = d3.select(this.canvas);
- svg.selectAll("*").remove();
+ const needsBuild = !this._built;
+ const dimsChanged = this._chartWidth !== chartWidth || this._chartHeight !== chartHeight;
+
+ this.focusIndex = undefined;
+
+ // — Structural phase (first time, resize, or repo switch) —
+ if (needsBuild || dimsChanged) {
+ svg.selectAll("*").remove();
+ svg.append("defs");
+ this._g = svg.append("g")
+ .attr("class", "chart-container")
+ .attr("transform", `translate(${this.margin.left},${this.margin.top})`);
+ this._chartWidth = chartWidth;
+ this._chartHeight = chartHeight;
+ this._built = true;
+ }
+
+ const g = this._g;
- // Containers
- const g = svg.append("g")
- .attr("transform", `translate(${this.margin.left},${this.margin.top})`);
+ // — Clear data-driven children (keep g itself, defs, and their attributes) —
+ g.selectAll("g.axis-y, g.axis-x, .milestone-marker, path.layer, .scrubber-line, rect[fill='transparent'], text.axis-label").remove();
+ svg.select("defs").selectAll("linearGradient").remove();
- // Scales
+ // — Scales —
const xScale = d3.scaleTime()
.domain(d3.extent(this.points, d => d.date))
.range([0, chartWidth]);
+ this.xScale = xScale;
const maxTotal = d3.max(this.points, d => d.total);
let yScale;
@@ -191,34 +316,29 @@ class TheseusVisualizer {
.range([chartHeight, 0]);
}
- // Color Logic & Gradients
- const defs = svg.append("defs");
-
+ // — Gradients —
+ const defs = svg.select("defs");
const getBaseColor = (seriesName, seriesIndex) => {
if (this.vizMode === 'identity') {
- return (seriesIndex === 0) ? '#3bc7c7' : '#f0a33b';
+ return (seriesIndex === 0) ? 'oklch(68% 0.14 195)' : 'oklch(72% 0.16 65)';
}
const yearIdx = this.years.indexOf(seriesName);
- return `hsl(${(180 + yearIdx * 40) % 360}, 85%, 70%)`;
+ return `oklch(70% 0.14 ${(195 + yearIdx * 36) % 360})`;
};
- // Create gradients for each series
- const seriesKeys = this.vizMode === 'identity' ? [this.years[0], 'refactored'] : this.years;
this.years.forEach((year, i) => {
const color = getBaseColor(year, i);
const grad = defs.append("linearGradient")
.attr("id", `grad-${year}`)
.attr("x1", "0%").attr("y1", "0%")
.attr("x2", "0%").attr("y2", "100%");
-
grad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.9);
grad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.4);
});
- // Specialized gradients for Identity mode if needed
if (this.vizMode === 'identity') {
['original', 'refactored'].forEach(id => {
- const color = id === 'original' ? '#3bc7c7' : '#f0a33b';
+ const color = id === 'original' ? 'oklch(68% 0.14 195)' : 'oklch(72% 0.16 65)';
const grad = defs.append("linearGradient")
.attr("id", `grad-id-${id}`)
.attr("x1", "0%").attr("y1", "0%")
@@ -228,7 +348,7 @@ class TheseusVisualizer {
});
}
- // Stack & Area
+ // — Stack & Area (data join) —
const stackGenerator = d3.stack()
.keys(this.years);
@@ -240,7 +360,6 @@ class TheseusVisualizer {
.y1(d => yScale(this.yScaleMode === 'log' ? Math.max(1, d[1]) : d[1]))
.curve(d3.curveMonotoneX);
- // Render Layers (Data Join)
const layers = g.selectAll(".layer")
.data(stackedData, d => d.key);
@@ -259,21 +378,22 @@ class TheseusVisualizer {
.attr("d", areaGenerator)
.style("opacity", 0)
.transition()
- .duration(800)
+ .duration(this.animDuration)
+ .delay((d, i) => this.reducedMotion ? 0 : i * 50)
.style("opacity", 1);
layers.transition()
- .duration(800)
+ .duration(this.animDuration)
.attr("d", areaGenerator)
.attr("fill", getFill);
layers.exit().remove();
- // Interaction Components (Legend, Axes, Scrubber)
- this.renderLegend();
+ // — Axes, Milestones, Interaction, Legend —
this.renderAxes(g, chartWidth, chartHeight, xScale, yScale);
this.renderMilestoneMarkers(g, chartWidth, chartHeight, xScale);
this.setupInteractivity(g, chartWidth, chartHeight, xScale, yScale);
+ this.renderLegend();
}
renderMilestoneMarkers(g, chartWidth, chartHeight, xScale) {
@@ -290,6 +410,9 @@ class TheseusVisualizer {
const marker = g.append('g')
.attr('class', 'milestone-marker')
.attr('transform', `translate(${xPos}, 0)`)
+ .attr('tabindex', '0')
+ .attr('role', 'button')
+ .attr('aria-label', `${m.title}: ${m.description}`)
.style('cursor', 'pointer');
marker.append('text')
@@ -297,73 +420,113 @@ class TheseusVisualizer {
.attr('y', 18)
.attr('text-anchor', 'middle')
.attr('font-size', '14px')
- .attr('fill', '#3bc7c7')
+ .attr('fill', 'oklch(68% 0.14 195)')
.text('★')
.style('opacity', 0.8)
- .style('filter', 'drop-shadow(0 0 4px rgba(59, 199, 199, 0.6))');
+ .style('filter', 'drop-shadow(0 0 4px oklch(68% 0.14 195 / 0.6))');
marker.append('title')
.text(m.title + ': ' + m.description);
- marker.on('mouseenter', function () {
+ const animDur = this.reducedMotion ? 0 : 200;
+
+ const enlarge = function () {
d3.select(this).select('text')
.transition()
- .duration(200)
+ .duration(animDur)
.attr('font-size', '18px')
.style('opacity', 1);
- });
+ };
- marker.on('mouseleave', function () {
+ const shrink = function () {
d3.select(this).select('text')
.transition()
- .duration(200)
+ .duration(animDur)
.attr('font-size', '14px')
.style('opacity', 0.8);
- });
+ };
+
+ marker.on('mouseenter', enlarge)
+ .on('mouseleave', shrink)
+ .on('focus', enlarge)
+ .on('blur', shrink);
}
});
}
renderLegend() {
this.legend.innerHTML = '';
- const items = this.vizMode === 'identity'
- ? [{ label: 'Original Code', color: '#3bc7c7' }, { label: 'Refactored', color: '#f0a33b' }]
- : this.years.map((y, i) => ({ label: y, color: `hsl(${(180 + i * 40) % 360}, 85%, 70%)` }));
+
+ let items;
+ if (this.vizMode === 'identity') {
+ items = [{ label: 'Original Code', color: 'oklch(68% 0.14 195)' }, { label: 'Refactored', color: 'oklch(72% 0.16 65)' }];
+ } else {
+ items = this.years.map((y, i) => ({ label: y, color: `oklch(70% 0.14 ${(195 + i * 36) % 360})` }));
+ }
+
+ // Trigger pill
+ const years = this.years;
+ const triggerText = this.vizMode === 'identity'
+ ? 'Original + Refactored · 2 layers'
+ : `${years[0]}–${years[years.length - 1]} · ${years.length} years`;
+
+ const trigger = document.createElement('button');
+ trigger.className = 'legend-trigger';
+ trigger.setAttribute('aria-expanded', 'false');
+ trigger.setAttribute('aria-label', `Legend: ${triggerText}. Hover or focus to expand.`);
+ trigger.innerHTML = `${triggerText}`;
+ this.legend.appendChild(trigger);
+
+ // Panel
+ const panel = document.createElement('div');
+ panel.className = 'legend-panel';
+ panel.setAttribute('role', 'tooltip');
+ this.legend.appendChild(panel);
items.forEach(item => {
- const div = document.createElement('div');
- div.className = 'legend-item';
- div.style.cursor = 'pointer';
- div.innerHTML = `
-
- ${item.label}
- `;
-
- div.onmouseenter = () => {
+ const el = document.createElement('div');
+ el.className = 'legend-item';
+ el.innerHTML = `${item.label}`;
+
+ el.onmouseenter = () => {
const label = item.label;
- const firstYear = this.years[0];
+ const firstYear = years[0];
- d3.selectAll(".chart-area").style("opacity", 0.1);
+ d3.selectAll('.chart-area').style('opacity', 0.1);
if (this.vizMode === 'identity') {
if (label === 'Original Code') {
- d3.selectAll(`.chart-area[data-year='${firstYear}']`).style("opacity", 1);
+ d3.selectAll(`.chart-area[data-year='${firstYear}']`).style('opacity', 1);
} else {
- // All years except the first one
- d3.selectAll(".chart-area")
- .filter(function () { return d3.select(this).attr("data-year") !== firstYear; })
- .style("opacity", 1);
+ d3.selectAll('.chart-area')
+ .filter(function () { return d3.select(this).attr('data-year') !== firstYear; })
+ .style('opacity', 1);
}
} else {
- d3.selectAll(`.chart-area[data-year='${label}']`).style("opacity", 1);
+ d3.selectAll(`.chart-area[data-year='${label}']`).style('opacity', 1);
}
};
- div.onmouseleave = () => {
- d3.selectAll(".chart-area").style("opacity", 1);
+ el.onmouseleave = () => {
+ d3.selectAll('.chart-area').style('opacity', 1);
};
- this.legend.appendChild(div);
+ panel.appendChild(el);
+ });
+
+ // Toggle aria-expanded on hover
+ const toggleExpanded = (expanded) => {
+ trigger.setAttribute('aria-expanded', String(expanded));
+ };
+
+ this.legend.addEventListener('mouseenter', () => toggleExpanded(true));
+ this.legend.addEventListener('mouseleave', () => toggleExpanded(false));
+
+ this.legend.addEventListener('focusin', (e) => {
+ if (this.legend.contains(e.target)) toggleExpanded(true);
+ });
+ this.legend.addEventListener('focusout', (e) => {
+ if (!this.legend.contains(e.relatedTarget)) toggleExpanded(false);
});
}
@@ -383,13 +546,13 @@ class TheseusVisualizer {
.call(yAxis);
yGroup.selectAll(".tick line")
- .attr("stroke", "#374151")
+ .attr("stroke", "oklch(30% 0.01 260)")
.attr("stroke-dasharray", "3,3")
.attr("stroke-opacity", 0.5);
yGroup.selectAll("text")
.attr("x", -10)
- .attr("fill", "#6b7280")
+ .attr("fill", "oklch(55% 0.015 255)")
.attr("font-size", "10px")
.attr("font-family", "inherit");
@@ -407,20 +570,20 @@ class TheseusVisualizer {
xGroup.selectAll("text")
.attr("y", 15)
- .attr("fill", "#8b949e")
+ .attr("fill", "oklch(55% 0.015 255)")
.attr("font-size", "11px")
.attr("letter-spacing", "0.05em")
.attr("font-family", "inherit");
- xGroup.select(".domain").attr("stroke", "rgba(255, 255, 255, 0.1)");
- xGroup.selectAll(".tick line").attr("stroke", "rgba(255, 255, 255, 0.1)");
+ xGroup.select(".domain").attr("stroke", "oklch(100% 0 0 / 0.1)");
+ xGroup.selectAll(".tick line").attr("stroke", "oklch(100% 0 0 / 0.1)");
// Axis Labels
g.append("text")
.attr("class", "axis-label")
.attr("x", width / 2)
.attr("y", height + 40)
- .attr("fill", "#6b7280")
+ .attr("fill", "oklch(55% 0.015 255)")
.attr("font-size", "12px")
.attr("text-anchor", "middle")
.text("Time");
@@ -430,7 +593,7 @@ class TheseusVisualizer {
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", -45)
- .attr("fill", "#6b7280")
+ .attr("fill", "oklch(55% 0.015 255)")
.attr("font-size", "12px")
.attr("text-anchor", "middle")
.text("Lines of Code");
@@ -441,21 +604,25 @@ class TheseusVisualizer {
.attr("class", "scrubber-line hidden")
.attr("y1", 0)
.attr("y2", height)
- .attr("stroke", "rgba(255,255,255,0.2)")
+ .attr("stroke", "oklch(100% 0 0 / 0.2)")
.attr("stroke-width", 1);
-
const bisect = d3.bisector(d => d.date).left;
+ const self = this;
+
g.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "transparent")
- .on("mousemove", (event) => {
+ .attr("tabindex", "0")
+ .attr("role", "img")
+ .attr("aria-label", "Chart of code composition by year. Use Arrow Left and Arrow Right to navigate data points.")
+ .on("mousemove", function (event) {
const mouseX = d3.pointer(event)[0];
const date = xScale.invert(mouseX);
- const idx = bisect(this.points, date, 1);
- const d0 = this.points[idx - 1];
- const d1 = this.points[idx];
+ const idx = bisect(self.points, date, 1);
+ const d0 = self.points[idx - 1];
+ const d1 = self.points[idx];
// Handle single-point or edge cases
if (!d0 && !d1) return;
@@ -467,17 +634,47 @@ class TheseusVisualizer {
const snappedX = xScale(d.date);
scrubber.attr("x1", snappedX).attr("x2", snappedX).classed("hidden", false);
- const svgRect = this.canvas.getBoundingClientRect();
- this.showTooltip(d, snappedX + this.margin.left, d3.pointer(event)[1] + this.margin.top);
+ const svgRect = self.canvas.getBoundingClientRect();
+ self.showTooltip(d, snappedX + self.margin.left, d3.pointer(event)[1] + self.margin.top);
})
.on("mouseleave", () => {
- this.hideTooltip();
+ self.hideTooltip();
+ scrubber.classed("hidden", true);
+ })
+ .on("focus", () => {
+ if (self.focusIndex === undefined || self.focusIndex >= self.points.length) {
+ self.focusIndex = self.points.length - 1;
+ }
+ self.updateFocusPoint(xScale, scrubber);
+ })
+ .on("blur", () => {
+ self.hideTooltip();
scrubber.classed("hidden", true);
+ })
+ .on("keydown", (event) => {
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
+ event.preventDefault();
+ const len = self.points.length;
+ if (len === 0) return;
+
+ if (self.focusIndex === undefined) {
+ self.focusIndex = len - 1;
+ }
+
+ if (event.key === "ArrowLeft") {
+ self.focusIndex = Math.max(0, self.focusIndex - 1);
+ } else {
+ self.focusIndex = Math.min(len - 1, self.focusIndex + 1);
+ }
+
+ self.updateFocusPoint(xScale, scrubber);
+ }
});
}
showTooltip(point, x, y) {
this.tooltip.classList.remove('hidden');
+ this.tooltip.setAttribute('role', 'tooltip');
const dateStr = point.date instanceof Date
? point.date.toISOString().split('T')[0]
@@ -526,7 +723,7 @@ class TheseusVisualizer {