diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md
index 8bb25f6d8..bd8ad7690 100644
--- a/LOTUS_FEATURES.md
+++ b/LOTUS_FEATURES.md
@@ -294,6 +294,7 @@ A warm orange overlay rendered over the entire UI to reduce blue light emission.
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
- Orange tint color with configurable opacity
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
+- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:00–07:00.
- Settings persisted via the standard Lotus settings store
---
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 8d660baf2..193cd1e9a 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -96,6 +96,7 @@ import {
useThemes,
} from '../../../hooks/useTheme';
import { stopPropagation } from '../../../utils/keyboard';
+import { pickerInputStyle } from '../../../utils/datetimeInput';
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
@@ -435,6 +436,12 @@ function Appearance() {
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
+ const [nightLightSchedule, setNightLightSchedule] = useSetting(
+ settingsAtom,
+ 'nightLightSchedule',
+ );
+ const [nightLightStart, setNightLightStart] = useSetting(settingsAtom, 'nightLightStart');
+ const [nightLightEnd, setNightLightEnd] = useSetting(settingsAtom, 'nightLightEnd');
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
settingsAtom,
'glassmorphismSidebar',
@@ -567,23 +574,66 @@ function Appearance() {
{nightLightEnabled && (
-
- Intensity: {nightLightOpacity}%
-
- ) =>
- setNightLightOpacity(parseInt(e.target.value, 10))
- }
- style={{ width: '100%', accentColor: color.Primary.Main }}
- />
+
+
+ Intensity: {nightLightOpacity}%
+
+ ) =>
+ setNightLightOpacity(parseInt(e.target.value, 10))
+ }
+ style={{ width: '100%', accentColor: color.Primary.Main }}
+ />
+
+
+
+ Schedule
+
+ Only tint during the hours below
+
+
+
+
+ {nightLightSchedule && (
+
+
+
+ From
+
+ setNightLightStart(e.target.value)}
+ style={pickerInputStyle(color, config)}
+ />
+
+
+
+ To
+
+ setNightLightEnd(e.target.value)}
+ style={pickerInputStyle(color, config)}
+ />
+
+
+ )}
)}
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 0632a2777..36abe4ae4 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode, useEffect } from 'react';
+import React, { ReactNode, useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import {
@@ -23,6 +23,7 @@ import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
+import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -128,7 +129,23 @@ function DesktopChrome({ children }: { children: ReactNode }) {
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
+ const scheduled = settings.nightLightEnabled && settings.nightLightSchedule;
+ // When scheduled, re-evaluate the active window every minute so the overlay
+ // turns on/off automatically at the start/end times without a reload.
+ const [, forceTick] = useState(0);
+ useEffect(() => {
+ if (!scheduled) return undefined;
+ const id = setInterval(() => forceTick((n) => n + 1), 60_000);
+ return () => clearInterval(id);
+ }, [scheduled]);
+
if (!settings.nightLightEnabled) return null;
+ if (
+ settings.nightLightSchedule &&
+ !isWithinTimeWindow(settings.nightLightStart, settings.nightLightEnd)
+ ) {
+ return null;
+ }
return (
new Date(2026, 0, 5, h, m);
+
+test('parseHHMM: valid values', () => {
+ assert.equal(parseHHMM('00:00'), 0);
+ assert.equal(parseHHMM('07:30'), 7 * 60 + 30);
+ assert.equal(parseHHMM('23:59'), 23 * 60 + 59);
+});
+
+test('parseHHMM: malformed values return null', () => {
+ assert.equal(parseHHMM(''), null);
+ assert.equal(parseHHMM('7:30'), null);
+ assert.equal(parseHHMM('24:00'), null);
+ assert.equal(parseHHMM('12:60'), null);
+ assert.equal(parseHHMM('abc'), null);
+});
+
+test('isWithinTimeWindow: same-day window [09:00, 17:00)', () => {
+ assert.equal(isWithinTimeWindow('09:00', '17:00', at(8, 59)), false);
+ assert.equal(isWithinTimeWindow('09:00', '17:00', at(9, 0)), true);
+ assert.equal(isWithinTimeWindow('09:00', '17:00', at(16, 59)), true);
+ assert.equal(isWithinTimeWindow('09:00', '17:00', at(17, 0)), false); // end exclusive
+});
+
+test('isWithinTimeWindow: overnight window [21:00, 07:00) wraps midnight', () => {
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(20, 59)), false);
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(21, 0)), true);
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(23, 59)), true);
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(0, 0)), true);
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(6, 59)), true);
+ assert.equal(isWithinTimeWindow('21:00', '07:00', at(7, 0)), false); // end exclusive
+});
+
+test('isWithinTimeWindow: zero-length window is never active', () => {
+ assert.equal(isWithinTimeWindow('08:00', '08:00', at(8, 0)), false);
+ assert.equal(isWithinTimeWindow('08:00', '08:00', at(12, 0)), false);
+});
+
+test('isWithinTimeWindow: malformed inputs are inactive', () => {
+ assert.equal(isWithinTimeWindow('', '07:00', at(3, 0)), false);
+ assert.equal(isWithinTimeWindow('21:00', 'nope', at(3, 0)), false);
+});
diff --git a/src/app/utils/timeWindow.ts b/src/app/utils/timeWindow.ts
new file mode 100644
index 000000000..9de2520f1
--- /dev/null
+++ b/src/app/utils/timeWindow.ts
@@ -0,0 +1,22 @@
+// Parse an "HH:mm" (24h) string into minutes-since-midnight, or null if malformed.
+export function parseHHMM(value: string): number | null {
+ const m = /^(\d{2}):(\d{2})$/.exec(value);
+ if (!m) return null;
+ const hours = Number(m[1]);
+ const minutes = Number(m[2]);
+ if (hours > 23 || minutes > 59) return null;
+ return hours * 60 + minutes;
+}
+
+// Is `now`'s wall-clock time inside the [start, end) window? The window may wrap
+// past midnight (start > end, e.g. 21:00 → 07:00). A zero-length window
+// (start === end) is treated as never active. Malformed inputs → false.
+export function isWithinTimeWindow(start: string, end: string, now: Date = new Date()): boolean {
+ const s = parseHHMM(start);
+ const e = parseHHMM(end);
+ if (s === null || e === null || s === e) return false;
+ const cur = now.getHours() * 60 + now.getMinutes();
+ if (s < e) return cur >= s && cur < e;
+ // Overnight wrap: active from start until midnight, then midnight until end.
+ return cur >= s || cur < e;
+}