docs(phase-6): research renderer architecture refactor
This commit is contained in:
parent
6162ffabaf
commit
530c2b2158
1 changed files with 395 additions and 0 deletions
395
.planning/phases/06-renderer-architecture/06-RESEARCH.md
Normal file
395
.planning/phases/06-renderer-architecture/06-RESEARCH.md
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
# Phase 6: Renderer Architecture - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-09
|
||||||
|
**Domain:** TypeScript strategy pattern refactor for d3.js multi-mode heatmap
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 6 is a pure refactoring phase: decompose the 413-line monolithic `heatmap.ts` into a strategy-pattern renderer system where adding a new visualization mode means implementing a `ModeRenderer` interface and registering it. No new features ship -- the year-view must render identically to v1.0.
|
||||||
|
|
||||||
|
The current code has clear seams for extraction. `renderHeatmap()` handles cell generation, color scaling, tooltips, and click handlers all inline. `init()` manages state (current data, active project filter) via closure variables. The refactor creates three layers: (1) a `HeatmapState` object owning mode/metric/filter state, (2) shared utilities (tooltip, color scale, cell click), and (3) mode-specific renderers that consume state and utilities.
|
||||||
|
|
||||||
|
**Primary recommendation:** Use TypeScript interfaces + a renderer registry map. No external libraries needed -- this is a structural refactor of existing code with existing dependencies.
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (no changes from v1.0)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| d3-selection | ^3.0.0 | DOM binding | Already in use, all renderers will use it |
|
||||||
|
| d3-scale | ^4.0.2 | Color quantization | Shared color scale utility |
|
||||||
|
| d3-time | ^3.1.0 | Date math | Year-mode cell layout |
|
||||||
|
| d3-time-format | ^4.1.0 | Date formatting | Tooltip display |
|
||||||
|
| d3-array | ^3.2.4 | Data aggregation | max(), extent() for scales |
|
||||||
|
|
||||||
|
### Testing (no changes)
|
||||||
|
| Library | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| vitest | ^4.1.3 | Test runner |
|
||||||
|
| jsdom | ^29.0.2 | DOM environment |
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
This phase adds zero new packages. The strategy pattern and state management are pure TypeScript constructs. [VERIFIED: codebase analysis]
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Current Structure (v1.0)
|
||||||
|
```
|
||||||
|
assets/src/
|
||||||
|
heatmap.ts # 413 lines -- everything in one file
|
||||||
|
types.ts # DayEntry, HeatmapData, HeatmapConfig, ProjectOption
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target Structure (Phase 6)
|
||||||
|
```
|
||||||
|
assets/src/
|
||||||
|
types.ts # Extended with HeatmapState, ModeRenderer, DisplayMetric
|
||||||
|
state.ts # HeatmapState class -- mode, metric, filters, data
|
||||||
|
renderers/
|
||||||
|
registry.ts # Mode -> Renderer map, getRenderer()
|
||||||
|
year.ts # YearModeRenderer -- extracted from current renderHeatmap()
|
||||||
|
types.ts # ModeRenderer interface, RenderContext
|
||||||
|
shared/
|
||||||
|
tooltip.ts # createTooltip(), showTooltip(), hideTooltip()
|
||||||
|
color-scale.ts # buildColorScale() -- shared quantize scale factory
|
||||||
|
cells.ts # Cell click handler factory, cell class builder
|
||||||
|
stats.ts # calculateStreak(), calculateStats(), renderStats()
|
||||||
|
date-utils.ts # generateCells(), buildDateMap(), getWeekInterval()
|
||||||
|
heatmap.ts # Slim orchestrator: init() + doRender() dispatching to registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: ModeRenderer Interface
|
||||||
|
**What:** Each visualization mode implements a common interface. The orchestrator asks the registry for the current mode's renderer and calls `render()`.
|
||||||
|
**When to use:** Every new mode (week, day, combined) implements this interface.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// assets/src/renderers/types.ts
|
||||||
|
export interface RenderContext {
|
||||||
|
container: HTMLElement; // The SVG area div
|
||||||
|
data: HeatmapData; // Raw day entries from API
|
||||||
|
state: HeatmapState; // Current mode, metric, filters
|
||||||
|
config: HeatmapConfig; // Cell size, margins
|
||||||
|
onCellClick?: (dateStr: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModeRenderer {
|
||||||
|
readonly mode: string; // 'year' | 'week' | 'day' | 'combined'
|
||||||
|
render(ctx: RenderContext): void;
|
||||||
|
destroy?(): void; // Cleanup tooltips, listeners
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[ASSUMED -- interface design based on strategy pattern best practices]
|
||||||
|
|
||||||
|
### Pattern 2: HeatmapState
|
||||||
|
**What:** A single state object tracks the current mode, display metric, and active filters. UI changes mutate state, then trigger a re-render.
|
||||||
|
**When to use:** Any time a user action changes what the heatmap displays.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// assets/src/state.ts
|
||||||
|
export type DisplayMetric = 'hours' | 'count';
|
||||||
|
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
projectId: number | null;
|
||||||
|
customerId: number | null;
|
||||||
|
activityId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapState {
|
||||||
|
mode: HeatmapMode;
|
||||||
|
metric: DisplayMetric;
|
||||||
|
filters: FilterState;
|
||||||
|
weekStart: string;
|
||||||
|
data: HeatmapData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState(weekStart: string): HeatmapState {
|
||||||
|
return {
|
||||||
|
mode: 'year',
|
||||||
|
metric: 'hours',
|
||||||
|
filters: { projectId: null, customerId: null, activityId: null },
|
||||||
|
weekStart,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[ASSUMED -- state shape designed to support Phases 7-10 requirements]
|
||||||
|
|
||||||
|
### Pattern 3: Renderer Registry
|
||||||
|
**What:** A map from mode string to renderer instance. New modes register themselves; the orchestrator just does `registry.get(state.mode).render(ctx)`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// assets/src/renderers/registry.ts
|
||||||
|
import type { ModeRenderer } from './types';
|
||||||
|
|
||||||
|
const renderers = new Map<string, ModeRenderer>();
|
||||||
|
|
||||||
|
export function registerRenderer(renderer: ModeRenderer): void {
|
||||||
|
renderers.set(renderer.mode, renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRenderer(mode: string): ModeRenderer {
|
||||||
|
const r = renderers.get(mode);
|
||||||
|
if (!r) throw new Error(`Unknown heatmap mode: ${mode}`);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[ASSUMED -- standard registry pattern]
|
||||||
|
|
||||||
|
### Pattern 4: Shared Utility Extraction
|
||||||
|
**What:** Tooltip, color scale, and click handler logic pulled out of `renderHeatmap()` into reusable functions.
|
||||||
|
|
||||||
|
The current code has these inline:
|
||||||
|
- **Tooltip** (lines 94-99, 284-296): `createTooltip()`, mouseenter/mouseleave handlers
|
||||||
|
- **Color scale** (lines 200-205): `scaleQuantize` setup from data max
|
||||||
|
- **Cell click** (lines 297-300): click handler calling `onCellClick(dateStr)`
|
||||||
|
- **Stats** (lines 101-174): `calculateStreak()`, `calculateStats()`, `renderStats()`
|
||||||
|
- **Date utils** (lines 47-92): `buildDateMap()`, `generateCells()`, `getWeekInterval()`
|
||||||
|
|
||||||
|
Each becomes a standalone module importable by any renderer.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **God state object with methods:** Keep state as plain data. Renderers and the orchestrator read it; only `init()` mutates it. No class with 15 methods.
|
||||||
|
- **Renderer knowing about DOM outside its container:** Each renderer gets a `container` div and only touches that. Tooltip appends to `document.body` but that's the tooltip utility's concern, not the renderer's.
|
||||||
|
- **Event listener leaks:** Each renderer must clean up its tooltip and event listeners in `destroy()` before a new render. The current code already handles this (`container.innerHTML = ''` and removing stale tooltips).
|
||||||
|
- **Breaking the IIFE global:** The esbuild output is `--format=iife --global-name=KimaiHeatmap`. The entry point must continue to export `init` on that global. Internal module structure is esbuild's concern.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Observable state / pub-sub | Custom event system | Callback-based re-render | This widget has one consumer (the orchestrator). A reactive framework is massive overkill for a single widget. Mutate state, call `doRender()`. |
|
||||||
|
| Module bundling | Custom loader | esbuild (already configured) | esbuild handles the multi-file TS -> single IIFE bundle. No config changes needed for adding files. |
|
||||||
|
| Color scale | Custom interpolation | d3-scale `scaleQuantize` | Already in use. Extract, don't rewrite. |
|
||||||
|
|
||||||
|
**Key insight:** This refactor is about decomposition, not new capabilities. Every function already exists in `heatmap.ts` -- the work is extraction and interface definition.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Breaking the esbuild IIFE entry point
|
||||||
|
**What goes wrong:** Refactoring the entry point changes what `KimaiHeatmap.init` resolves to in the browser.
|
||||||
|
**Why it happens:** Moving `init()` to a different file or changing its export signature.
|
||||||
|
**How to avoid:** Keep `assets/src/heatmap.ts` as the entry point. It imports everything internally and exports `init`. The esbuild command stays identical.
|
||||||
|
**Warning signs:** `KimaiHeatmap.init is not a function` in browser console.
|
||||||
|
|
||||||
|
### Pitfall 2: Tooltip cleanup between renders
|
||||||
|
**What goes wrong:** Stale tooltips pile up on `document.body` when switching modes or re-rendering.
|
||||||
|
**Why it happens:** Each `render()` creates a new tooltip div but doesn't remove the old one.
|
||||||
|
**How to avoid:** The tooltip utility must track and clean up its previous instance. Current code already does `document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove())` -- centralize this in the tooltip module.
|
||||||
|
**Warning signs:** Multiple `.heatmap-tooltip` elements in DOM inspector.
|
||||||
|
|
||||||
|
### Pitfall 3: Over-engineering state management
|
||||||
|
**What goes wrong:** Building a reactive store, subscriptions, or middleware for a widget that has one consumer.
|
||||||
|
**Why it happens:** Applying SPA patterns to a dashboard widget.
|
||||||
|
**How to avoid:** Plain object + imperative `doRender()` call. The `init()` function owns the render loop. State changes -> call `doRender()`. That's it.
|
||||||
|
**Warning signs:** Words like "subscribe", "dispatch", "reducer" appearing in widget code.
|
||||||
|
|
||||||
|
### Pitfall 4: Tests coupled to implementation details
|
||||||
|
**What goes wrong:** Existing tests break because they import internal functions that moved files.
|
||||||
|
**Why it happens:** Tests import `calculateStreak` from `'../src/heatmap'` -- if that function moves to `'../src/shared/stats'`, imports break.
|
||||||
|
**How to avoid:** Re-export moved functions from `heatmap.ts` (barrel pattern) OR update all test imports in the same commit. The second approach is cleaner -- tests should import from the module that owns the function.
|
||||||
|
**Warning signs:** Test files with `import from '../src/heatmap'` for functions that now live elsewhere.
|
||||||
|
|
||||||
|
### Pitfall 5: Visual regression from refactor
|
||||||
|
**What goes wrong:** SVG output changes subtly (attribute order, class names, positioning) and nobody notices.
|
||||||
|
**Why it happens:** Extracting code sometimes changes initialization order or default values.
|
||||||
|
**How to avoid:** Add a snapshot test that captures the SVG output of `renderHeatmap()` with known data. Compare before and after refactor. The existing tests already check cell count, classes, and tooltips -- they're good regression guards.
|
||||||
|
**Warning signs:** Heatmap "looks slightly off" after deploy.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Extracting the tooltip utility
|
||||||
|
```typescript
|
||||||
|
// assets/src/shared/tooltip.ts
|
||||||
|
export function createTooltip(): HTMLDivElement {
|
||||||
|
// Clean up any stale tooltips
|
||||||
|
document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
|
||||||
|
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.className = 'heatmap-tooltip';
|
||||||
|
tip.style.display = 'none';
|
||||||
|
tip.style.position = 'fixed';
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTooltip(
|
||||||
|
tip: HTMLDivElement,
|
||||||
|
html: string,
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
cellSize: number,
|
||||||
|
): void {
|
||||||
|
tip.innerHTML = html;
|
||||||
|
tip.style.display = 'block';
|
||||||
|
tip.style.left = `${anchorRect.left + cellSize / 2}px`;
|
||||||
|
tip.style.top = `${anchorRect.top - tip.offsetHeight - 8}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideTooltip(tip: HTMLDivElement): void {
|
||||||
|
tip.style.display = 'none';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Source: extracted from current heatmap.ts lines 94-99, 284-296 [VERIFIED: codebase]
|
||||||
|
|
||||||
|
### Extracting the color scale
|
||||||
|
```typescript
|
||||||
|
// assets/src/shared/color-scale.ts
|
||||||
|
import { scaleQuantize } from 'd3-scale';
|
||||||
|
import { max } from 'd3-array';
|
||||||
|
import type { DayEntry, DisplayMetric } from '../types';
|
||||||
|
|
||||||
|
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
||||||
|
|
||||||
|
export function buildColorScale(
|
||||||
|
days: DayEntry[],
|
||||||
|
metric: DisplayMetric = 'hours',
|
||||||
|
): ReturnType<typeof scaleQuantize<string>> {
|
||||||
|
const accessor = metric === 'hours' ? (d: DayEntry) => d.hours : (d: DayEntry) => d.count;
|
||||||
|
const maxVal = max(days, accessor) || 1;
|
||||||
|
return scaleQuantize<string>().domain([0, maxVal]).range(FALLBACK_COLORS);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Source: extracted from current heatmap.ts lines 200-205 [VERIFIED: codebase]
|
||||||
|
|
||||||
|
### Year renderer implementing ModeRenderer
|
||||||
|
```typescript
|
||||||
|
// assets/src/renderers/year.ts (sketch)
|
||||||
|
import type { ModeRenderer, RenderContext } from './types';
|
||||||
|
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
|
||||||
|
import { buildColorScale } from '../shared/color-scale';
|
||||||
|
import { generateCells, buildDateMap, getWeekInterval } from '../shared/date-utils';
|
||||||
|
// ... d3 imports
|
||||||
|
|
||||||
|
export class YearModeRenderer implements ModeRenderer {
|
||||||
|
readonly mode = 'year';
|
||||||
|
private tooltip: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
render(ctx: RenderContext): void {
|
||||||
|
ctx.container.innerHTML = '';
|
||||||
|
this.tooltip = createTooltip();
|
||||||
|
// ... existing renderHeatmap() logic using ctx.data, ctx.state, ctx.config
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.tooltip?.remove();
|
||||||
|
this.tooltip = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[ASSUMED -- implementation sketch, actual extraction follows current code structure]
|
||||||
|
|
||||||
|
### Slim orchestrator init
|
||||||
|
```typescript
|
||||||
|
// assets/src/heatmap.ts (after refactor, sketch)
|
||||||
|
import { createInitialState } from './state';
|
||||||
|
import { getRenderer, registerRenderer } from './renderers/registry';
|
||||||
|
import { YearModeRenderer } from './renderers/year';
|
||||||
|
import { renderStats } from './shared/stats';
|
||||||
|
|
||||||
|
// Register built-in renderers
|
||||||
|
registerRenderer(new YearModeRenderer());
|
||||||
|
|
||||||
|
export function init(container: HTMLElement): void {
|
||||||
|
const weekStart = container.getAttribute('data-week-start') || 'monday';
|
||||||
|
const state = createInitialState(weekStart);
|
||||||
|
|
||||||
|
// ... same data-url/projects parsing as current init()
|
||||||
|
|
||||||
|
const doRender = () => {
|
||||||
|
if (!state.data) return;
|
||||||
|
const renderer = getRenderer(state.mode);
|
||||||
|
renderer.destroy?.();
|
||||||
|
renderer.render({
|
||||||
|
container: svgArea,
|
||||||
|
data: state.data,
|
||||||
|
state,
|
||||||
|
config: DEFAULT_CONFIG,
|
||||||
|
onCellClick,
|
||||||
|
});
|
||||||
|
renderStats(container, state.data.days);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... fetch, filter, resize logic unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[ASSUMED -- orchestration sketch]
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Vitest ^4.1.3 |
|
||||||
|
| Config file | `vitest.config.ts` |
|
||||||
|
| Quick run command | `npm test` |
|
||||||
|
| Full suite command | `npm test` |
|
||||||
|
|
||||||
|
### Phase Requirements -> Test Map
|
||||||
|
|
||||||
|
Phase 6 has no formal requirement IDs (it is an architectural enabler). Testing maps to success criteria:
|
||||||
|
|
||||||
|
| Criterion | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|-----------|----------|-----------|-------------------|-------------|
|
||||||
|
| SC-1 | Year-view renders identically to v1.0 | unit | `npx vitest run assets/test/heatmap.test.ts` | Yes (existing) |
|
||||||
|
| SC-2 | Shared utilities are importable and work | unit | `npx vitest run assets/test/tooltip.test.ts` | No -- Wave 0 |
|
||||||
|
| SC-3 | HeatmapState tracks mode/metric/filters | unit | `npx vitest run assets/test/state.test.ts` | No -- Wave 0 |
|
||||||
|
| SC-4 | Registry dispatches to correct renderer | unit | `npx vitest run assets/test/registry.test.ts` | No -- Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `npm test`
|
||||||
|
- **Per wave merge:** `npm test`
|
||||||
|
- **Phase gate:** All existing tests pass + new unit tests for extracted modules
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `assets/test/state.test.ts` -- covers SC-3 (HeatmapState creation and mutation)
|
||||||
|
- [ ] `assets/test/registry.test.ts` -- covers SC-4 (renderer registration and lookup)
|
||||||
|
- [ ] `assets/test/tooltip.test.ts` -- covers SC-2 (tooltip create/show/hide lifecycle)
|
||||||
|
- [ ] `assets/test/color-scale.test.ts` -- covers SC-2 (color scale with hours and count metrics)
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | ModeRenderer interface shape (mode, render, destroy) | Architecture Patterns | Low -- interface is internal, easily adjusted before Phase 7 |
|
||||||
|
| A2 | HeatmapState includes customerId/activityId fields for future phases | Architecture Patterns | Low -- adding fields later is trivial |
|
||||||
|
| A3 | DisplayMetric 'hours' vs 'count' toggle reads from state.metric | Code Examples | Low -- this is the only sensible design given VIZ-05 |
|
||||||
|
| A4 | Plain object state + imperative re-render is sufficient (no reactive store) | Common Pitfalls | Medium -- if filter interactions become complex in Phase 10, may need pub/sub. But can add later. |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Should stats rendering move inside the renderer or stay in the orchestrator?**
|
||||||
|
- What we know: Stats (streak, total, avg, busiest) are mode-agnostic in v1.0 but may differ by mode later (week-mode might show "busiest weekday" instead of "busiest day").
|
||||||
|
- What's unclear: Whether future modes need custom stats.
|
||||||
|
- Recommendation: Keep `renderStats()` in the orchestrator for now. If a mode needs custom stats, the `ModeRenderer` interface can gain an optional `renderStats()` method later.
|
||||||
|
|
||||||
|
2. **Should the filter dropdown stay in `init()` or become its own module?**
|
||||||
|
- What we know: Phase 10 replaces the plain `<select>` with TomSelect cascading pickers.
|
||||||
|
- What's unclear: Whether extracting filters now saves work in Phase 10.
|
||||||
|
- Recommendation: Leave filter construction in `init()` for Phase 6. Phase 10 will gut it anyway. Extracting now is churn.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Codebase analysis: `assets/src/heatmap.ts` (413 lines), `assets/src/types.ts`, all test files
|
||||||
|
- `package.json` -- verified dependencies and build config
|
||||||
|
- `vitest.config.ts` -- verified test setup
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- None needed -- this phase is purely about restructuring existing code
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH -- no new dependencies, verified from package.json
|
||||||
|
- Architecture: HIGH -- based on direct analysis of 413-line source file with clear extraction seams
|
||||||
|
- Pitfalls: HIGH -- derived from actual code patterns (tooltip cleanup, esbuild IIFE, test imports)
|
||||||
|
|
||||||
|
**Research date:** 2026-04-09
|
||||||
|
**Valid until:** 2026-05-09 (stable -- no external dependencies changing)
|
||||||
Loading…
Add table
Reference in a new issue