feat(night-light): optional auto-on schedule (time window)
Night Light was all-or-nothing. Add an optional schedule with From/To time inputs so the warm overlay only shows during set hours and toggles 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 utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default 21:00-07:00). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
|
||||||
- Orange tint color with configurable opacity
|
- Orange tint color with configurable opacity
|
||||||
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% 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
|
- Settings persisted via the standard Lotus settings store
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import {
|
|||||||
useThemes,
|
useThemes,
|
||||||
} from '../../../hooks/useTheme';
|
} from '../../../hooks/useTheme';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { pickerInputStyle } from '../../../utils/datetimeInput';
|
||||||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||||||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||||
@@ -435,6 +436,12 @@ function Appearance() {
|
|||||||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
||||||
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
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(
|
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'glassmorphismSidebar',
|
'glassmorphismSidebar',
|
||||||
@@ -567,23 +574,66 @@ function Appearance() {
|
|||||||
{nightLightEnabled && (
|
{nightLightEnabled && (
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="300"
|
||||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||||
>
|
>
|
||||||
<Text size="T200" priority="300">
|
<Box direction="Column" gap="100">
|
||||||
Intensity: {nightLightOpacity}%
|
<Text size="T200" priority="300">
|
||||||
</Text>
|
Intensity: {nightLightOpacity}%
|
||||||
<input
|
</Text>
|
||||||
aria-label="Night light intensity"
|
<input
|
||||||
type="range"
|
aria-label="Night light intensity"
|
||||||
min={5}
|
type="range"
|
||||||
max={80}
|
min={5}
|
||||||
value={nightLightOpacity}
|
max={80}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
value={nightLightOpacity}
|
||||||
setNightLightOpacity(parseInt(e.target.value, 10))
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
}
|
setNightLightOpacity(parseInt(e.target.value, 10))
|
||||||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
}
|
||||||
/>
|
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Box direction="Column">
|
||||||
|
<Text size="T300">Schedule</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Only tint during the hours below
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={nightLightSchedule}
|
||||||
|
onChange={setNightLightSchedule}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{nightLightSchedule && (
|
||||||
|
<Box gap="200">
|
||||||
|
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||||
|
<Text as="label" htmlFor="night-light-start" size="T200" priority="400">
|
||||||
|
From
|
||||||
|
</Text>
|
||||||
|
<input
|
||||||
|
id="night-light-start"
|
||||||
|
type="time"
|
||||||
|
value={nightLightStart}
|
||||||
|
onChange={(e) => setNightLightStart(e.target.value)}
|
||||||
|
style={pickerInputStyle(color, config)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||||
|
<Text as="label" htmlFor="night-light-end" size="T200" priority="400">
|
||||||
|
To
|
||||||
|
</Text>
|
||||||
|
<input
|
||||||
|
id="night-light-end"
|
||||||
|
type="time"
|
||||||
|
value={nightLightEnd}
|
||||||
|
onChange={(e) => setNightLightEnd(e.target.value)}
|
||||||
|
style={pickerInputStyle(color, config)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
+18
-1
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react';
|
import React, { ReactNode, useEffect, useState } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,7 @@ import { createRouter } from './Router';
|
|||||||
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
||||||
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
@@ -128,7 +129,23 @@ function DesktopChrome({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function NightLightOverlay() {
|
function NightLightOverlay() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
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.nightLightEnabled) return null;
|
||||||
|
if (
|
||||||
|
settings.nightLightSchedule &&
|
||||||
|
!isWithinTimeWindow(settings.nightLightStart, settings.nightLightEnd)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export interface Settings {
|
|||||||
|
|
||||||
nightLightEnabled: boolean;
|
nightLightEnabled: boolean;
|
||||||
nightLightOpacity: number;
|
nightLightOpacity: number;
|
||||||
|
nightLightSchedule: boolean;
|
||||||
|
nightLightStart: string;
|
||||||
|
nightLightEnd: string;
|
||||||
|
|
||||||
glassmorphismSidebar: boolean;
|
glassmorphismSidebar: boolean;
|
||||||
|
|
||||||
@@ -329,6 +332,9 @@ const defaultSettings: Settings = {
|
|||||||
pttMode: false,
|
pttMode: false,
|
||||||
pttKey: 'Space',
|
pttKey: 'Space',
|
||||||
|
|
||||||
|
nightLightSchedule: false,
|
||||||
|
nightLightStart: '21:00',
|
||||||
|
nightLightEnd: '07:00',
|
||||||
nightLightEnabled: false,
|
nightLightEnabled: false,
|
||||||
nightLightOpacity: 30,
|
nightLightOpacity: 30,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { parseHHMM, isWithinTimeWindow } from './timeWindow';
|
||||||
|
|
||||||
|
const at = (h: number, m: number): Date => 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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user