From 11b7723e4bd8b36648465940fb64e72177f9080b Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:53:39 +0200 Subject: [PATCH 1/4] Add Wave 13 sprint-record plan (projector CSS filters) --- docs/sprint-records/wave-13.md | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/sprint-records/wave-13.md diff --git a/docs/sprint-records/wave-13.md b/docs/sprint-records/wave-13.md new file mode 100644 index 0000000..1f6cb09 --- /dev/null +++ b/docs/sprint-records/wave-13.md @@ -0,0 +1,67 @@ +# Wave 13 — v0.3.0 projector CSS filter panel + +Status: Proposed +Owner: 900 Labs +Scope target: `docs/ROADMAP.md` v0.3.0 ("Projector CSS filter panel in +presenter mode: invert, brightness, contrast, saturation, sepia, hue-rotate, +persisted per device.") +Last updated: 2026-07-29 + +A small, self-contained wave. The presenter gains a CSS filter panel for +projector compensation: invert, brightness, contrast, saturation, sepia, +and hue-rotate sliders applied to the audience window. Settings persist to +the app data directory (per-device, not per-deck). + +## What this wave delivers + +| # | Component | Crate / file | New vs. extend | +| --- | --- | --- | --- | +| 1 | Model | `crates/slides-core/src/lib.rs` | `ProjectorFilters` on `PresenterSettings` | +| 2 | Desktop | `apps/desktop/` | Filter panel UI + CSS application + persistence | + +## Model changes (additive) + +Add to `PresenterSettings`: + +```rust +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectorFilters { + #[serde(default)] + pub invert: bool, + #[serde(default = "default_brightness")] + pub brightness: f64, // 0.0..=2.0, default 1.0 + #[serde(default = "default_contrast")] + pub contrast: f64, // 0.0..=2.0, default 1.0 + #[serde(default = "default_saturation")] + pub saturation: f64, // 0.0..=2.0, default 1.0 + #[serde(default)] + pub sepia: f64, // 0.0..=1.0, default 0.0 + #[serde(default)] + pub hue_rotate: f64, // 0.0..=360.0 degrees, default 0.0 +} +``` + +Add `pub projector_filters: ProjectorFilters` to `PresenterSettings` +(`#[serde(default)]`). + +No new commands — the existing `SetPresenterSettings` command (Wave 8) +already replaces the entire settings struct. + +## Desktop + +- A **filter panel** in the presenter window (toggle button → popover with + sliders for brightness, contrast, saturation, sepia, hue-rotate + an + invert checkbox + a reset button). +- The CSS `filter` property is applied to the audience window's slide + container element: `filter: invert(1) brightness(1.2) contrast(1.1) ...`. +- Filters persist via `SetPresenterSettings` (stored on the deck's + `presenter_settings.projector_filters`). +- The audience window applies the filter in real time via Tauri events. + +## Acceptance criteria + +1. The presenter has a filter panel with all 6 controls. +2. Adjusting filters applies CSS to the audience window in real time. +3. Filters persist on the deck via PresenterSettings. +4. Old decks (no projector_filters) default to neutral (no filtering). +5. Quality gate green. Privacy gate passes. No telemetry. From a60bf843b861bec2dedb6bda7ccbc2d854d2d82c Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:55:22 +0200 Subject: [PATCH 2/4] slides-core: ProjectorFilters on PresenterSettings (Wave 13, model) --- crates/slides-core/src/lib.rs | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/slides-core/src/lib.rs b/crates/slides-core/src/lib.rs index f595939..fe27d05 100644 --- a/crates/slides-core/src/lib.rs +++ b/crates/slides-core/src/lib.rs @@ -183,6 +183,56 @@ pub struct PresenterSettings { /// Highlighter color (hex). Defaults to yellow. #[serde(default = "default_highlighter_color")] pub highlighter_color: String, + /// Projector compensation filters (brightness, contrast, etc.). + #[serde(default)] + pub projector_filters: ProjectorFilters, +} + +/// CSS filters applied to the audience window for projector compensation. +/// Persisted per-deck via `PresenterSettings`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectorFilters { + /// Invert all colors. + #[serde(default)] + pub invert: bool, + /// Brightness multiplier (1.0 = normal, 0.0 = black, 2.0 = double). + #[serde(default = "default_brightness")] + pub brightness: f64, + /// Contrast multiplier (1.0 = normal). + #[serde(default = "default_contrast")] + pub contrast: f64, + /// Saturation multiplier (1.0 = normal, 0.0 = grayscale). + #[serde(default = "default_saturation")] + pub saturation: f64, + /// Sepia intensity (0.0 = none, 1.0 = full sepia). + #[serde(default)] + pub sepia: f64, + /// Hue rotation in degrees (0.0 = none, 360.0 = full rotation). + #[serde(default)] + pub hue_rotate: f64, +} + +impl Default for ProjectorFilters { + fn default() -> Self { + Self { + invert: false, + brightness: default_brightness(), + contrast: default_contrast(), + saturation: default_saturation(), + sepia: 0.0, + hue_rotate: 0.0, + } + } +} + +fn default_brightness() -> f64 { + 1.0 +} +fn default_contrast() -> f64 { + 1.0 +} +fn default_saturation() -> f64 { + 1.0 } impl Default for PresenterSettings { @@ -192,6 +242,7 @@ impl Default for PresenterSettings { laser_color: default_laser_color(), highlighter: false, highlighter_color: default_highlighter_color(), + projector_filters: ProjectorFilters::default(), } } } @@ -7455,6 +7506,7 @@ mod tests { laser_color: "#00ff00".to_string(), highlighter: true, highlighter_color: "#0000ff".to_string(), + projector_filters: ProjectorFilters::default(), }; let mut bus = CommandBus::default(); bus.apply( From f35360208024316529d7215ab601f396fdf4e9e9 Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:05:19 +0200 Subject: [PATCH 3/4] desktop: projector CSS filter panel in presenter (Wave 13, component 2) --- apps/desktop/src-tauri/src/commands.rs | 75 ++++++++++ apps/desktop/src/AudienceWindow.svelte | 18 ++- apps/desktop/src/Presenter.svelte | 188 ++++++++++++++++++++++++- apps/desktop/src/lib/presenter.ts | 57 +++++++- apps/desktop/src/lib/types.ts | 18 +++ 5 files changed, 352 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 5ccb2e0..a5e799a 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -274,6 +274,9 @@ pub struct PresenterSettingsDto { /// Highlighter color as a CSS hex string. Defaults to yellow. #[serde(default = "default_highlighter_color_dto")] pub highlighter_color: String, + /// Projector compensation CSS filters applied to the audience window. + #[serde(default)] + pub projector_filters: ProjectorFiltersDto, } impl Default for PresenterSettingsDto { @@ -283,6 +286,7 @@ impl Default for PresenterSettingsDto { laser_color: default_laser_color_dto(), highlighter: false, highlighter_color: default_highlighter_color_dto(), + projector_filters: ProjectorFiltersDto::default(), } } } @@ -297,6 +301,75 @@ fn default_highlighter_color_dto() -> String { String::from("#ffff00") } +/// Projector compensation CSS filters, mirroring +/// [`slides_core::ProjectorFilters`]. Serialized to the audience window as a +/// CSS `filter` string. Old snapshots without `projectorFilters` deserialize +/// to the neutral default via the field-level `#[serde(default)]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectorFiltersDto { + /// Invert all colors. + #[serde(default)] + pub invert: bool, + /// Brightness multiplier (1.0 = normal, 0.0 = black, 2.0 = double). + #[serde(default = "default_filter_unit")] + pub brightness: f64, + /// Contrast multiplier (1.0 = normal). + #[serde(default = "default_filter_unit")] + pub contrast: f64, + /// Saturation multiplier (1.0 = normal, 0.0 = grayscale). + #[serde(default = "default_filter_unit")] + pub saturation: f64, + /// Sepia intensity (0.0 = none, 1.0 = full sepia). + #[serde(default)] + pub sepia: f64, + /// Hue rotation in degrees (0.0 = none, 360.0 = full rotation). + #[serde(default)] + pub hue_rotate: f64, +} + +impl Default for ProjectorFiltersDto { + fn default() -> Self { + Self { + invert: false, + brightness: default_filter_unit(), + contrast: default_filter_unit(), + saturation: default_filter_unit(), + sepia: 0.0, + hue_rotate: 0.0, + } + } +} + +/// Neutral multiplier default (1.0) for brightness / contrast / saturation. +fn default_filter_unit() -> f64 { + 1.0 +} + +/// Converts a model [`slides_core::ProjectorFilters`] into its DTO. +fn projector_filters_to_dto(filters: &slides_core::ProjectorFilters) -> ProjectorFiltersDto { + ProjectorFiltersDto { + invert: filters.invert, + brightness: filters.brightness, + contrast: filters.contrast, + saturation: filters.saturation, + sepia: filters.sepia, + hue_rotate: filters.hue_rotate, + } +} + +/// Converts a [`ProjectorFiltersDto`] into the model type. +fn projector_filters_from_dto(dto: &ProjectorFiltersDto) -> slides_core::ProjectorFilters { + slides_core::ProjectorFilters { + invert: dto.invert, + brightness: dto.brightness, + contrast: dto.contrast, + saturation: dto.saturation, + sepia: dto.sepia, + hue_rotate: dto.hue_rotate, + } +} + /// Converts a model [`slides_core::PresenterSettings`] into its DTO. fn presenter_settings_to_dto(settings: &slides_core::PresenterSettings) -> PresenterSettingsDto { PresenterSettingsDto { @@ -304,6 +377,7 @@ fn presenter_settings_to_dto(settings: &slides_core::PresenterSettings) -> Prese laser_color: settings.laser_color.clone(), highlighter: settings.highlighter, highlighter_color: settings.highlighter_color.clone(), + projector_filters: projector_filters_to_dto(&settings.projector_filters), } } @@ -314,6 +388,7 @@ fn presenter_settings_from_dto(dto: &PresenterSettingsDto) -> slides_core::Prese laser_color: dto.laser_color.clone(), highlighter: dto.highlighter, highlighter_color: dto.highlighter_color.clone(), + projector_filters: projector_filters_from_dto(&dto.projector_filters), } } diff --git a/apps/desktop/src/AudienceWindow.svelte b/apps/desktop/src/AudienceWindow.svelte index fdd329b..acbf363 100644 --- a/apps/desktop/src/AudienceWindow.svelte +++ b/apps/desktop/src/AudienceWindow.svelte @@ -2,9 +2,11 @@ import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn, type Event } from '@tauri-apps/api/event' import SlideCanvas from './SlideCanvas.svelte' - import type { ColorDto, PresenterState } from './lib/types' + import type { ColorDto, PresenterState, ProjectorFiltersDto } from './lib/types' import { PRESENTER_EVENTS, + defaultProjectorFilters, + projectorFilterCss, runMorph, slideRectFromStage, type BlankMode, @@ -26,6 +28,10 @@ let strokes = $state([]) /** Audience blank mode. */ let blankMode = $state('none') + /** Projector CSS filters applied to the slide container. */ + let appliedFilters = $state(defaultProjectorFilters()) + /** CSS `filter` string derived from {@link appliedFilters}. */ + let filterCss = $derived(projectorFilterCss(appliedFilters)) /** Bound stage element, used to measure the rendered slide. */ let stageEl = $state(null) /** Rendered slide box in stage-local pixels. */ @@ -40,7 +46,10 @@ const unlisteners: Array<() => void> = [] invoke('get_presenter_state').then((state) => { - if (!cancelled) presenterState = state + if (!cancelled) { + presenterState = state + appliedFilters = state.presenterSettings.projectorFilters ?? defaultProjectorFilters() + } }) /** Registers a listener and ensures it is torn down even if the effect is. */ @@ -55,6 +64,7 @@ on(PRESENTER_EVENTS.state, (state) => { presenterState = state + appliedFilters = state.presenterSettings.projectorFilters ?? defaultProjectorFilters() }) on<{ step: number }>(PRESENTER_EVENTS.buildStep, (payload) => { activeBuildStep = payload.step @@ -71,6 +81,9 @@ on<{ mode: BlankMode }>(PRESENTER_EVENTS.blank, (payload) => { blankMode = payload.mode }) + on(PRESENTER_EVENTS.filters, (payload) => { + appliedFilters = payload + }) on(PRESENTER_EVENTS.morph, (payload) => { morph = payload }) @@ -154,6 +167,7 @@
(null) + /** Debounced persistence of projector filters (coalesces slider drags). */ + let persistFilters: ((settings: PresenterSettingsDto) => void) | null = null + /** Active Magic Move morph: the previous slide rendered as an overlay while * matching shapes interpolate between slides. */ let morph = $state(null) @@ -84,6 +95,11 @@ emitHighlighter = throttle((s: HighlighterStroke[]) => { void emit(PRESENTER_EVENTS.highlighter, { strokes: s }) }, 33) + // Projector filter writes are debounced so a slider drag produces a single + // SetPresenterSettings call rather than one per input tick. + persistFilters = debounce((settings: PresenterSettingsDto) => { + void invoke('set_presenter_settings', { settings }) + }, 300) }) // Keep the main slide box measurement current as the window resizes. @@ -318,10 +334,33 @@ laserColor: '#ff0000', highlighter: false, highlighterColor: '#ffff00', + projectorFilters: defaultProjectorFilters(), } ) } + /** Returns the current projector filters (neutral when no deck is open). */ + function currentFilters(): ProjectorFiltersDto { + return presenterState?.presenterSettings.projectorFilters ?? defaultProjectorFilters() + } + + /** + * Applies a new projector filter set: updates the local source of truth, + * broadcasts it to the audience window for an immediate CSS update, and + * schedules a debounced persistence write. The presenter's own view is never + * filtered — only the audience (projector) window is. + */ + function updateFilters(next: ProjectorFiltersDto): void { + if (!presenterState) return + const updated: PresenterSettingsDto = { + ...presenterState.presenterSettings, + projectorFilters: next, + } + presenterState.presenterSettings = updated + void emit(PRESENTER_EVENTS.filters, next) + persistFilters?.(updated) + } + /** Persists presenter settings to the deck (colors and tool defaults). */ async function persistSettings(next: PresenterSettingsDto): Promise { if (presenterState) presenterState.presenterSettings = next @@ -388,6 +427,14 @@ if (drawing) drawing = false } + /** Closes the filter popover on any outside click, then advances when not drawing. */ + function onWindowClick(event: MouseEvent): void { + if (filterPanelEl && filterPanelEl.contains(event.target as Node)) return + filterPanelOpen = false + if (highlighterOn) return + next() + } + /** Keyboard control for presenter navigation and tools. */ function handleKey(event: KeyboardEvent): void { if (event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'PageDown') { @@ -451,7 +498,7 @@ } - (highlighterOn ? undefined : next())} onpointerup={onWindowPointerUp} /> +
{#if presenterState} @@ -562,6 +609,99 @@ persistSettings({ ...settings(), highlighterColor: e.currentTarget.value })} /> + + + {#if filterPanelOpen} +
+ + + + + + + +
+ {/if} +
{#if blankMode !== 'none'} {blankMode === 'black' ? 'B' : 'W'} @@ -682,6 +822,52 @@ background: #fff; color: #000; } + .filter-tool { + position: relative; + display: inline-flex; + } + .filter-panel { + position: absolute; + top: calc(100% + 0.4rem); + left: 0; + z-index: 50; + width: 240px; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: #1c1c1c; + border: 1px solid #444; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + } + .filter-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + } + .filter-row.check { + flex-direction: row; + align-items: center; + gap: 0.4rem; + } + .filter-label { + display: flex; + justify-content: space-between; + font-variant-numeric: tabular-nums; + } + .filter-label em { + font-style: normal; + color: #9bc1ff; + } + .filter-row input[type='range'] { + width: 100%; + cursor: pointer; + } + .filter-panel .reset { + align-self: flex-end; + } .next { flex: 0 0 auto; } diff --git a/apps/desktop/src/lib/presenter.ts b/apps/desktop/src/lib/presenter.ts index 53ccc4d..23a7b34 100644 --- a/apps/desktop/src/lib/presenter.ts +++ b/apps/desktop/src/lib/presenter.ts @@ -1,4 +1,4 @@ -import type { MorphFrameDto, SlideSnapshot } from './types' +import type { MorphFrameDto, ProjectorFiltersDto, SlideSnapshot } from './types' /** * Shared contract for the dual-display presenter. @@ -43,6 +43,8 @@ export const PRESENTER_EVENTS = { highlighter: 'presenter:highlighter', /** Audience blank mode. */ blank: 'presenter:blank', + /** Projector CSS filters applied to the audience slide container. */ + filters: 'presenter:filters', /** Magic Move morph payload: previous slide + interpolation frames. */ morph: 'presenter:morph', /** Both windows should close. */ @@ -119,6 +121,40 @@ export function clamp01(v: number): number { return v } +/** Neutral projector filters (no visible effect), matching the Rust default. */ +export function defaultProjectorFilters(): ProjectorFiltersDto { + return { + invert: false, + brightness: 1, + contrast: 1, + saturation: 1, + sepia: 0, + hueRotate: 0, + } +} + +/** Trims floating-point noise from a slider value for a clean CSS number. */ +function cleanNumber(v: number): number { + return Number(v.toFixed(4)) +} + +/** + * Builds a CSS `filter` string for the audience window's slide container, e.g. + * `invert(1) brightness(1.2) contrast(1.1) saturate(0.9) sepia(0.2) + * hue-rotate(45deg)`. Only properties that differ from their neutral default + * are emitted, so a reset yields an empty string (clearing the filter). + */ +export function projectorFilterCss(filters: ProjectorFiltersDto): string { + const parts: string[] = [] + if (filters.invert) parts.push('invert(1)') + if (filters.brightness !== 1) parts.push(`brightness(${cleanNumber(filters.brightness)})`) + if (filters.contrast !== 1) parts.push(`contrast(${cleanNumber(filters.contrast)})`) + if (filters.saturation !== 1) parts.push(`saturate(${cleanNumber(filters.saturation)})`) + if (filters.sepia !== 0) parts.push(`sepia(${cleanNumber(filters.sepia)})`) + if (filters.hueRotate !== 0) parts.push(`hue-rotate(${cleanNumber(filters.hueRotate)}deg)`) + return parts.join(' ') +} + /** * Leading + trailing throttle. The first call runs immediately; subsequent * calls within `ms` are coalesced into a single trailing call carrying the @@ -156,6 +192,25 @@ export function throttle( } } +/** + * Trailing debounce: collapses a burst of calls into a single trailing call + * carrying the latest arguments, fired `ms` after the last call. Used to + * coalesce rapid slider drags into one persistence write. + */ +export function debounce( + fn: (...args: A) => void, + ms: number, +): (...args: A) => void { + let handle: ReturnType | null = null + return (...args: A): void => { + if (handle) clearTimeout(handle) + handle = setTimeout(() => { + handle = null + fn(...args) + }, ms) + } +} + /** EMU -> CSS pixels, matching `SlideCanvas`. */ const MORPH_EMU_TO_PX = 1 / 9525 diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts index e9a8790..21c8ae0 100644 --- a/apps/desktop/src/lib/types.ts +++ b/apps/desktop/src/lib/types.ts @@ -395,6 +395,24 @@ export interface PresenterSettingsDto { highlighter: boolean /** Highlighter color as a CSS hex string (e.g. `#ffff00`). */ highlighterColor: string + /** Projector compensation CSS filters applied to the audience window. */ + projectorFilters: ProjectorFiltersDto +} + +/** Projector compensation CSS filters, mirroring slides-core ProjectorFilters. */ +export interface ProjectorFiltersDto { + /** Invert all colors. */ + invert: boolean + /** Brightness multiplier (1.0 = normal, 0.0 = black, 2.0 = double). */ + brightness: number + /** Contrast multiplier (1.0 = normal). */ + contrast: number + /** Saturation multiplier (1.0 = normal, 0.0 = grayscale). */ + saturation: number + /** Sepia intensity (0.0 = none, 1.0 = full sepia). */ + sepia: number + /** Hue rotation in degrees (0.0 = none, 360.0 = full rotation). */ + hueRotate: number } /** Presenter view state. */ From ea9e7a9232a6bd71205b7ddb5110ecdb55ab2c97 Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:07:44 +0200 Subject: [PATCH 4/4] Update README and CHANGELOG for Wave 13 projector filters --- CHANGELOG.md | 16 +++++++++++++++- README.md | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0613931..a7a73ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,24 @@ This section tracks work toward v0.2.0 (editor completeness). See [`docs/sprint-records/wave-9.md`](docs/sprint-records/wave-9.md), [`docs/sprint-records/wave-10.md`](docs/sprint-records/wave-10.md), and [`docs/sprint-records/wave-11.md`](docs/sprint-records/wave-11.md), and -[`docs/sprint-records/wave-12.md`](docs/sprint-records/wave-12.md). +[`docs/sprint-records/wave-12.md`](docs/sprint-records/wave-12.md), and +[`docs/sprint-records/wave-13.md`](docs/sprint-records/wave-13.md). ## [v0.3.0 — in progress] +### Added — Wave 13 (projector CSS filter panel) + +- The presenter gains a **projector compensation filter panel**: invert, + brightness, contrast, saturation, sepia, and hue-rotate controls applied + to the audience window via CSS `filter`. A toggle button in the presenter + toolbar opens a popover with sliders + a reset button. +- New `ProjectorFilters` type on `PresenterSettings` (additive, + `#[serde(default)]`) persists the settings on the deck. Slider changes + are debounced to coalesce into a single `SetPresenterSettings` call. +- The CSS filter string only includes non-default properties (e.g. + `brightness(1)` is omitted) for clean output. +- `docs/sprint-records/wave-13.md` documenting the wave scope. + ### Added — Wave 12 (bundled fonts + stepped code highlighting) - **Bundled open-licensed fonts**: Inter (sans), Source Serif 4 (serif), diff --git a/README.md b/README.md index e65c995..13ca8ef 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ What v0.1.0 does: - Presents a deck locally in **dual-display mode**: a presenter window (controls, notes, timer, next-slide preview) and a separate fullscreen audience window. Includes a **laser pointer** (`L`), **highlighter** - (`H`), and **black/white slide** (`B`/`W`) for Q&A. Keyboard navigation + (`H`), **black/white slide** (`B`/`W`) for Q&A, and a **projector filter + panel** (invert, brightness, contrast, saturation, sepia, hue-rotate). + Keyboard navigation only. - Recovers work after a crash or accidental quit via debounced autosave snapshots and a startup recovery prompt.