Skip to content

Commit 7680a75

Browse files
Gnathonicclaude
andauthored
feat(reader): B&W (grayscale) filter with shared scheduled-filter control (#221) (#222)
* docs(bw-filter): add design spec for B&W grayscale filter (#221) Mirrors the invert-mode design (manual + scheduled) and refactors the shared settings control into one reusable card used by night mode, invert, and B&W. Adds a centralized imageFilter store and a G hotkey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(bw-filter): add implementation plan for B&W grayscale filter (#221) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(settings): add grayscale setting, schedule, and grayscaleActive store Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(settings): add combined imageFilter derived store Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(reader): add reusable ScheduledFilterCard settings component Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(settings-ui): use ScheduledFilterCard for night/invert/B&W Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(bw-filter): fix task split so Reader.svelte filter+import change together Task 5 was changing Reader.svelte's filter to $imageFilter while deferring the imageFilter import to Task 6, which would leave $imageFilter undefined mid-task. Move Reader.svelte's filter-line change into Task 6 alongside its import-block rewrite; Task 5 now covers only the two scroll readers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(reader): apply combined imageFilter (invert + grayscale) in scroll readers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(reader): add B&W (G) hotkey via shared toggleScheduledFilter helper Routes KeyN/KeyI/KeyG through one helper and applies the combined imageFilter to the manga panel. Adds the G shortcut for the new grayscale filter (issue #221). Night mode's scheduled toast casing is normalized to "Night Mode" to share one label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: prettier formatting for B&W filter changes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e2166a commit 7680a75

10 files changed

Lines changed: 1481 additions & 177 deletions

File tree

docs/superpowers/plans/2026-06-06-bw-filter.md

Lines changed: 984 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# B&W (Grayscale) Filter — Design
2+
3+
**Issue:** [#221\[Feature\] B&W filter](https://github.com/Gnathonic/mokuro-reader/issues/221)
4+
**Date:** 2026-06-06
5+
**Branch:** `feat/bw-filter` (based on `develop`)
6+
7+
## Problem
8+
9+
Readers want to apply a black-and-white (grayscale) filter to pages. Use cases
10+
from the issue:
11+
12+
- A scan is "mostly" grayscale but not pure grayscale, and the color tint is
13+
distracting.
14+
- A colored scan is higher quality than the available B&W scan, but the reader
15+
prefers to read in black and white.
16+
17+
The reader already has a **night mode** filter and an **invert colors** filter,
18+
each with a Manual/Scheduled control and (for invert) a hotkey. B&W should fit
19+
in as a third member of that family.
20+
21+
## Goals
22+
23+
1. Add a B&W (grayscale) filter that mirrors the existing invert filter exactly:
24+
Manual toggle **and** time-based Scheduled mode, an "(active)" indicator, and
25+
a keyboard shortcut.
26+
2. Refactor the duplicated settings control so night mode, invert colors, and
27+
B&W all share **one reusable component** instead of three hand-written copies.
28+
3. Centralize the manga-panel CSS filter string so it lives in one place rather
29+
than being duplicated across the three reader components.
30+
31+
## Non-Goals
32+
33+
- Changing how **night mode** is _applied_. Night mode uses a separate global
34+
mechanism (`NightModeFilter.svelte`, the `<dialog>` filter trick). Only its
35+
_settings card_ is unified with invert/B&W; its application is untouched.
36+
- Adjustable filter intensity (e.g. partial grayscale). The filter is on/off,
37+
matching invert.
38+
- Per-volume B&W overrides beyond what the existing profile/volume settings
39+
system already provides for every setting.
40+
41+
## Approach
42+
43+
Chosen: **reusable card component + centralized filter store + DRY'd hotkey
44+
handler.** This adds the feature and removes the duplication in the same change,
45+
which is cleaner than copy-pasting a third filter card/handler/filter-string.
46+
47+
### 1. New setting (`src/lib/settings/settings.ts`)
48+
49+
Mirror `invertColors` / `invertColorsSchedule`:
50+
51+
- Add to the `Settings` type:
52+
- `grayscale: boolean`
53+
- `grayscaleSchedule: TimeSchedule`
54+
- Add to `defaultSettings`:
55+
- `grayscale: false`
56+
- `grayscaleSchedule: { enabled: false, startTime: '21:00', endTime: '06:00' }`
57+
- Add `'grayscaleSchedule'` to the `ScheduleSettingKey` union.
58+
- Add a migration block mirroring the `invertColorsSchedule` one so existing
59+
profiles get the new schedule object:
60+
```ts
61+
migratedProfile.grayscaleSchedule = {
62+
...defaultSettings.grayscaleSchedule,
63+
...(profile.grayscaleSchedule || {})
64+
};
65+
```
66+
- Add a derived store mirroring `invertColorsActive`:
67+
```ts
68+
export const grayscaleActive = derived([settings, currentMinute], ([$settings, _]) => {
69+
if (!$settings) return false;
70+
if ($settings.grayscaleSchedule?.enabled) {
71+
return isWithinSchedule($settings.grayscaleSchedule);
72+
}
73+
return $settings.grayscale ?? false;
74+
});
75+
```
76+
77+
### 2. New reusable component — `src/lib/components/Settings/Reader/ScheduledFilterCard.svelte`
78+
79+
Encapsulates the bordered card currently duplicated for night mode and invert:
80+
title + "(active)" badge, Manual/Scheduled radio pair (with hotkey hint on the
81+
Manual label), and either the enable toggle (manual) or the Start/End time
82+
pickers (scheduled).
83+
84+
Props:
85+
86+
| Prop | Type | Example |
87+
| ------------- | -------------------- | ------------------------ |
88+
| `title` | `string` | `'Black & white'` |
89+
| `enableLabel` | `string` | `'Enable black & white'` |
90+
| `hotkeyHint` | `string` | `'G'` |
91+
| `settingKey` | `SettingsKey` | `'grayscale'` |
92+
| `scheduleKey` | `ScheduleSettingKey` | `'grayscaleSchedule'` |
93+
| `active` | `boolean` | `$grayscaleActive` |
94+
95+
Internally it owns the `mode` derivation (`$settings[scheduleKey].enabled ?
96+
'scheduled' : 'manual'`) and the `setMode` logic that currently lives in
97+
`ReaderToggles.svelte` (switching to scheduled turns off the manual boolean).
98+
The radio-group `name` is derived from `scheduleKey` so the three card instances
99+
remain independent.
100+
101+
### 3. `src/lib/components/Settings/Reader/ReaderToggles.svelte`
102+
103+
Replace the two hand-written cards (night, invert) with **three**
104+
`<ScheduledFilterCard>` instances:
105+
106+
- Night mode — `settingKey="nightMode"`, `scheduleKey="nightModeSchedule"`,
107+
`hotkeyHint="N"`, `active={$nightModeActive}`
108+
- Invert colors — `settingKey="invertColors"`,
109+
`scheduleKey="invertColorsSchedule"`, `hotkeyHint="I"`,
110+
`active={$invertColorsActive}`
111+
- Black & white — `settingKey="grayscale"`,
112+
`scheduleKey="grayscaleSchedule"`, `hotkeyHint="G"`, `active={$grayscaleActive}`
113+
114+
Removes the now-unused `nightModeMode`, `invertMode`, `setNightModeMode`, and
115+
`setInvertMode` locals (moved into the component).
116+
117+
### 4. Centralized filter store (`settings.ts` + 3 readers)
118+
119+
Add a derived store combining the manga-panel filters:
120+
121+
```ts
122+
export const imageFilter = derived(
123+
[invertColorsActive, grayscaleActive],
124+
([$inv, $gray]) => `invert(${$inv ? 1 : 0}) grayscale(${$gray ? 1 : 0})`
125+
);
126+
```
127+
128+
`invert()` and `grayscale()` commute (grayscale is linear, invert is `1 − x`),
129+
so combining them in one string is correct regardless of order.
130+
131+
Replace the three inline usages of
132+
`style:filter={`invert(${$invertColorsActive ? 1 : 0})`}` with
133+
`style:filter={$imageFilter}` in:
134+
135+
- `src/lib/components/Reader/Reader.svelte`
136+
- `src/lib/components/Reader/HorizontalScrollReader.svelte`
137+
- `src/lib/components/Reader/VerticalScrollReader.svelte`
138+
139+
### 5. Hotkey (`src/lib/components/Reader/Reader.svelte`)
140+
141+
The `KeyN` and `KeyI` handlers share an identical shape: if the schedule is
142+
enabled, show an "on automatic schedule" notification; otherwise toggle the
143+
manual boolean and show an On/Off notification. Extract a helper:
144+
145+
```ts
146+
function toggleScheduledFilter(
147+
settingKey: 'nightMode' | 'invertColors' | 'grayscale',
148+
scheduleKey: ScheduleSettingKey,
149+
label: string, // e.g. 'Black & white', 'Invert', 'Night mode'
150+
notifPrefix: string // e.g. 'grayscale'
151+
) { ... }
152+
```
153+
154+
Route `KeyN`, `KeyI`, and the new **`KeyG`** through it. Keeps the three
155+
behaviors identical and adds B&W with one line.
156+
157+
Hotkey choice: **G** (grayscale). `B` was the alternative; `G` chosen.
158+
159+
## Testing
160+
161+
No tests currently cover `invertColorsActive` / `nightModeActive`. Add Vitest
162+
unit tests (TDD) for the new logic:
163+
164+
- `grayscaleActive`: returns the manual boolean when the schedule is disabled;
165+
returns the schedule result when enabled.
166+
- `imageFilter`: produces the correct combined string for each of the four
167+
invert/grayscale on/off combinations.
168+
169+
Manual verification:
170+
171+
- Toggle B&W via the settings card and via the **G** hotkey; both reflect each
172+
other and show the correct notification.
173+
- Filter applies in all three reader modes (paged, horizontal scroll, vertical
174+
scroll).
175+
- B&W combines correctly with invert (both on = inverted grayscale).
176+
- Scheduled mode activates/deactivates by time, and the "G" hotkey shows the
177+
"on automatic schedule" notice when scheduled.
178+
- Setting persists across reload and is included in profile export/sync like
179+
other settings.
180+
181+
## Files Touched
182+
183+
- `src/lib/settings/settings.ts` — type, defaults, `ScheduleSettingKey`,
184+
migration, `grayscaleActive`, `imageFilter`
185+
- `src/lib/components/Settings/Reader/ScheduledFilterCard.svelte`**new**
186+
- `src/lib/components/Settings/Reader/ReaderToggles.svelte` — use the component ×3
187+
- `src/lib/components/Reader/Reader.svelte` — hotkey helper + `KeyG` + `imageFilter`
188+
- `src/lib/components/Reader/HorizontalScrollReader.svelte``imageFilter`
189+
- `src/lib/components/Reader/VerticalScrollReader.svelte``imageFilter`
190+
- Test file(s) for `grayscaleActive` / `imageFilter`

src/lib/components/Reader/HorizontalScrollReader.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import type { Page, VolumeMetadata } from '$lib/types';
33
import type { VolumeSettings } from '$lib/settings/volume-data';
4-
import { settings, invertColorsActive } from '$lib/settings';
4+
import { settings, imageFilter } from '$lib/settings';
55
import { matchFilesToPages } from '$lib/reader/image-cache';
66
import { getCharCount } from '$lib/util/count-chars';
77
import { activityTracker } from '$lib/util/activity-tracker';
@@ -656,7 +656,7 @@
656656
class="flex"
657657
style:align-items={userZoom > 1 ? 'flex-start' : 'center'}
658658
style:direction={rtl ? 'rtl' : 'ltr'}
659-
style:filter={`invert(${$invertColorsActive ? 1 : 0})`}
659+
style:filter={$imageFilter}
660660
>
661661
<div
662662
bind:this={zoomWrapperEl}

src/lib/components/Reader/Reader.svelte

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@
1616
} from '$lib/panzoom';
1717
import {
1818
effectiveVolumeSettings,
19-
invertColorsActive,
19+
imageFilter,
2020
progress,
2121
settings,
2222
updateProgress,
2323
updateSetting,
2424
updateVolumeSetting,
2525
volumes,
2626
type VolumeSettings,
27-
type ContinuousZoomMode
27+
type ContinuousZoomMode,
28+
type ScheduleSettingKey
2829
} from '$lib/settings';
2930
import { clamp, debounce, fireExstaticEvent, resetScrollPosition } from '$lib/util';
3031
import { Input, Popover, Range, Spinner } from 'flowbite-svelte';
@@ -408,23 +409,13 @@
408409
toggleFullScreen();
409410
return;
410411
case 'KeyI':
411-
if ($settings.invertColorsSchedule.enabled) {
412-
showNotification('Invert is on automatic schedule', 'invert-scheduled');
413-
} else {
414-
updateSetting('invertColors', !$settings.invertColors);
415-
showNotification($settings.invertColors ? 'Invert Off' : 'Invert On', 'invert-toggle');
416-
}
412+
toggleScheduledFilter('invertColors', 'invertColorsSchedule', 'Invert', 'invert');
417413
return;
418414
case 'KeyN':
419-
if ($settings.nightModeSchedule.enabled) {
420-
showNotification('Night mode is on automatic schedule', 'nightmode-scheduled');
421-
} else {
422-
updateSetting('nightMode', !$settings.nightMode);
423-
showNotification(
424-
$settings.nightMode ? 'Night Mode Off' : 'Night Mode On',
425-
'nightmode-toggle'
426-
);
427-
}
415+
toggleScheduledFilter('nightMode', 'nightModeSchedule', 'Night Mode', 'nightmode');
416+
return;
417+
case 'KeyG':
418+
toggleScheduledFilter('grayscale', 'grayscaleSchedule', 'B&W', 'grayscale');
428419
return;
429420
case 'KeyC':
430421
if (volume) {
@@ -1134,6 +1125,28 @@
11341125
}, 2000);
11351126
}
11361127
1128+
// Shared toggle for the Manual/Scheduled display filters (night, invert, B&W).
1129+
// When the schedule owns the filter we only notify; otherwise we flip the
1130+
// manual boolean. This reproduces the original inline KeyI/KeyN handlers
1131+
// exactly, including reading $settings right after updateSetting for the
1132+
// On/Off label — keep this order and pattern; do not "simplify" it.
1133+
function toggleScheduledFilter(
1134+
settingKey: 'nightMode' | 'invertColors' | 'grayscale',
1135+
scheduleKey: ScheduleSettingKey,
1136+
label: string,
1137+
notifPrefix: string
1138+
) {
1139+
if ($settings[scheduleKey].enabled) {
1140+
showNotification(`${label} is on automatic schedule`, `${notifPrefix}-scheduled`);
1141+
} else {
1142+
updateSetting(settingKey, !$settings[settingKey]);
1143+
showNotification(
1144+
$settings[settingKey] ? `${label} Off` : `${label} On`,
1145+
`${notifPrefix}-toggle`
1146+
);
1147+
}
1148+
}
1149+
11371150
function rotateScrollMode() {
11381151
const current = $settings.scrollMode;
11391152
const order = ['auto', 'vertical', 'horizontal'] as const;
@@ -1420,7 +1433,7 @@
14201433
></button>
14211434
<div
14221435
class="grid"
1423-
style:filter={`invert(${$invertColorsActive ? 1 : 0})`}
1436+
style:filter={$imageFilter}
14241437
ondblclick={onDoubleTap}
14251438
onpointerdown={handleOverlayPointerDown}
14261439
onclick={handleOverlayToggle}

src/lib/components/Reader/VerticalScrollReader.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import type { Page, VolumeMetadata } from '$lib/types';
33
import type { VolumeSettings } from '$lib/settings/volume-data';
4-
import { settings, invertColorsActive } from '$lib/settings';
4+
import { settings, imageFilter } from '$lib/settings';
55
import { matchFilesToPages } from '$lib/reader/image-cache';
66
import { getCharCount } from '$lib/util/count-chars';
77
import { activityTracker } from '$lib/util/activity-tracker';
@@ -587,7 +587,7 @@
587587
style:overscroll-behavior="none"
588588
onscroll={handleScroll}
589589
>
590-
<div bind:this={zoomSpacerEl} style:filter={`invert(${$invertColorsActive ? 1 : 0})`}>
590+
<div bind:this={zoomSpacerEl} style:filter={$imageFilter}>
591591
<div bind:this={zoomWrapperEl} style:transform-origin="top left">
592592
<!-- Centering spacer -->
593593
<div style:height="50vh"></div>

0 commit comments

Comments
 (0)