diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte
new file mode 100644
index 00000000..1d9b9cf1
--- /dev/null
+++ b/frontend/src/lib/document/TimelineDensityFilter.svelte
@@ -0,0 +1,121 @@
+
+
+{#if density !== null}
+
+
+ {#each filled as bucket (bucket.month)}
+
+ {/each}
+
+
+ {#if hasSelection}
+
+ {/if}
+
+{/if}
+
+
diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
new file mode 100644
index 00000000..5d86ae09
--- /dev/null
+++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import TimelineDensityFilter from './TimelineDensityFilter.svelte';
+import type { components } from '$lib/generated/api';
+
+type MonthBucket = components['schemas']['MonthBucket'];
+
+afterEach(() => cleanup());
+
+const NOOP = () => undefined;
+
+function makeProps(overrides: Record = {}) {
+ return {
+ density: [
+ { month: '1915-08', count: 5 },
+ { month: '1915-09', count: 2 },
+ { month: '1915-10', count: 8 }
+ ] satisfies MonthBucket[],
+ minDate: '1915-08-01',
+ maxDate: '1915-10-31',
+ from: '',
+ to: '',
+ onchange: NOOP,
+ ...overrides
+ };
+}
+
+describe('TimelineDensityFilter — visibility', () => {
+ it('renders nothing when density is null', async () => {
+ render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null }));
+ expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
+ });
+
+ it('renders the widget when density is populated', async () => {
+ render(TimelineDensityFilter, makeProps());
+ await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
+ });
+
+ it('exposes an accessible group label on the widget', async () => {
+ render(TimelineDensityFilter, makeProps());
+ const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement;
+ expect(widget.getAttribute('role')).toBe('group');
+ expect(widget.getAttribute('aria-label')).toBeTruthy();
+ });
+});
+
+describe('TimelineDensityFilter — bars', () => {
+ it('renders one bar per month within the range, including zero-count gaps', async () => {
+ render(
+ TimelineDensityFilter,
+ makeProps({
+ density: [
+ { month: '1915-08', count: 5 },
+ { month: '1915-10', count: 8 }
+ ],
+ minDate: '1915-08-01',
+ maxDate: '1915-10-31'
+ })
+ );
+
+ const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
+ expect(bars.length).toBe(3);
+ });
+
+ it('zero-count months get the minimum visible bar height of 2px', async () => {
+ render(
+ TimelineDensityFilter,
+ makeProps({
+ density: [{ month: '1915-08', count: 4 }],
+ minDate: '1915-08-01',
+ maxDate: '1915-09-30'
+ })
+ );
+
+ const bars = document.querySelectorAll(
+ '[data-testid="timeline-bar"] .bar-fill'
+ ) as NodeListOf;
+ expect(bars.length).toBe(2);
+ expect(bars[1].style.height).toBe('2px');
+ });
+
+ it('renders an empty widget without crashing when density is empty array and no range', async () => {
+ render(TimelineDensityFilter, makeProps({ density: [], minDate: null, maxDate: null }));
+ await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
+ expect(document.querySelectorAll('[data-testid="timeline-bar"]').length).toBe(0);
+ });
+});
+
+describe('TimelineDensityFilter — selection', () => {
+ it('clicking a bar emits the boundary dates of that month via onchange', async () => {
+ const onchange = vi.fn();
+ render(TimelineDensityFilter, makeProps({ onchange }));
+
+ const bars = document.querySelectorAll(
+ '[data-testid="timeline-bar"]'
+ ) as NodeListOf;
+ bars[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ expect(onchange).toHaveBeenCalledTimes(1);
+ expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-08-31' });
+ });
+
+ it('shows a clear button when from/to are set', async () => {
+ render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
+ await expect.element(page.getByTestId('timeline-clear')).toBeInTheDocument();
+ });
+
+ it('does not show the clear button when from/to are empty', async () => {
+ render(TimelineDensityFilter, makeProps());
+ expect(document.querySelector('[data-testid="timeline-clear"]')).toBeNull();
+ });
+
+ it('clicking the clear button emits empty dates via onchange', async () => {
+ const onchange = vi.fn();
+ render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30', onchange }));
+
+ const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLButtonElement;
+ clearBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ expect(onchange).toHaveBeenCalledTimes(1);
+ expect(onchange).toHaveBeenCalledWith({ from: '', to: '' });
+ });
+
+ it('clear button is a real