Phase 14: bedtime nag + night mode (dark theme + falling stars)
Companion warmth: - ENCOURAGEMENTS grown 5->10; new BEDTIME lines (warm/playful, zh-first) - useCompanion heartbeat gains a bedtime branch (>=11pm, while actively writing) gated by a 30min cooldown; new 'bedtime' BubbleTone paces it Night mode (auto at ~11pm, local clock): - lib/night.ts centralizes isBedtime() + the window, shared with the nag - useNightMode toggles a `petal-night` class on <html>; index.css re-points only the palette tokens, so every Tailwind color utility flips via var() with no component edits (600ms dusk fade; print stays white) - PetalFall gains a `night` prop: chunky cartoon power stars (makeCartoonStar, 5 candy colors) mixed ~70/30 with twinkle sparkles; each star spins at its own rate (~0.5-2.2 rad/s, random direction), falls straight down, shimmers Verified: go vet/test, tsc, vitest 51/51, vite build; real-browser screenshots/video (clock mocked to 23:30). Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -14,10 +14,15 @@ import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
|
||||
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
||||
// layer so the petals become stars.
|
||||
const night = useNightMode()
|
||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -370,7 +375,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<PetalFall night={night} />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
|
||||
@@ -15,6 +15,11 @@ export const ENCOURAGEMENTS: Line[] = [
|
||||
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ zh: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||
]
|
||||
|
||||
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||
@@ -36,6 +41,16 @@ export const BREAKS: Line[] = [
|
||||
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
]
|
||||
|
||||
// Shown when she's still writing late at night (≥11pm). Caring, a little
|
||||
// playful — the kitten is always asleep, so "you should be too" lands as a gag,
|
||||
// never a scold. zh stays gentle; the English subtitle carries the wink.
|
||||
export const BEDTIME: Line[] = [
|
||||
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
]
|
||||
|
||||
// First hello when the app opens.
|
||||
export const GREETING: Line = {
|
||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BEDTIME,
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
@@ -14,12 +15,13 @@ import {
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
import { isBedtime } from '../../lib/night'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error'
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
|
||||
export interface Bubble extends Line {
|
||||
tone: BubbleTone
|
||||
}
|
||||
@@ -44,6 +46,9 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
|
||||
// The late-night window itself (isBedtime) lives in ../../lib/night so the
|
||||
// companion nag and the night-mode theme/starfall share one definition.
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
@@ -58,7 +63,7 @@ const now = () => Date.now()
|
||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||
// to actually finish reading.
|
||||
function readBubbleMs(b: Bubble): number {
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
|
||||
const chars = b.zh.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
@@ -75,6 +80,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
const lastProactive = useRef(0)
|
||||
const lastTip = useRef(0)
|
||||
const lastBreak = useRef(0)
|
||||
const lastBedtime = useRef(0)
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
const sleeping = useRef(false)
|
||||
|
||||
@@ -262,6 +268,15 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
return
|
||||
}
|
||||
|
||||
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
|
||||
// and only while she's actually still at it (the idle branch above already
|
||||
// returned if she's away/napping).
|
||||
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
||||
lastBedtime.current = t
|
||||
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise an occasional tip — context-aware when her text gives us
|
||||
// something concrete to gently point at, generic warmth otherwise.
|
||||
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||
|
||||
@@ -17,6 +17,23 @@ const PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 122, b: 172 }, // deep rose
|
||||
]
|
||||
|
||||
// Night-mode sparkle palette: soft starlight tones for the small twinkle accents
|
||||
// scattered between the chunky cartoon stars (moonlit white, warm gold).
|
||||
const STAR_PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 255, b: 255 }, // white
|
||||
{ r: 255, g: 240, b: 200 }, // warm gold
|
||||
]
|
||||
|
||||
// Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star
|
||||
// or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline.
|
||||
const CARTOON_COLORS: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 201, b: 56 }, // golden power-star
|
||||
{ r: 255, g: 150, b: 190 }, // bubblegum pink
|
||||
{ r: 130, g: 195, b: 255 }, // sky blue
|
||||
{ r: 150, g: 225, b: 190 }, // mint
|
||||
{ r: 200, g: 175, b: 255 }, // lavender
|
||||
]
|
||||
|
||||
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
|
||||
|
||||
function rgba(c: { r: number; g: number; b: number }, a: number): string {
|
||||
@@ -67,6 +84,118 @@ function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasEleme
|
||||
return cv
|
||||
}
|
||||
|
||||
// Draw one star sprite: a soft radial glow with a crisp four-point sparkle on
|
||||
// top, filled brightest at the core so it reads as a twinkling star.
|
||||
function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
// Soft halo behind the sparkle.
|
||||
const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5)
|
||||
glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5))
|
||||
glow.addColorStop(0.35, rgba(color, 0.16))
|
||||
glow.addColorStop(1, rgba(color, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, s * 0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Four-point sparkle: alternate a long outer radius with a small inner one so
|
||||
// the points pinch in sharply.
|
||||
const R = s * 0.46
|
||||
const r = s * 0.08
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ang = (i * Math.PI) / 4 - Math.PI / 2
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
g.closePath()
|
||||
const core = g.createRadialGradient(0, 0, 0, 0, 0, R)
|
||||
core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98))
|
||||
core.addColorStop(0.5, rgba(color, 0.9))
|
||||
core.addColorStop(1, rgba(color, 0.2))
|
||||
g.fillStyle = core
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
// Draw one chunky cartoon star — a fat five-point star with a glossy radial
|
||||
// fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline
|
||||
// (round line joins → Kirby-ish soft points), and a little corner shine. Reads
|
||||
// like a game power-up rather than a delicate sparkle.
|
||||
function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
const R = s * 0.4 // outer point radius
|
||||
const r = R * 0.46 // inner radius — small ⇒ chunky, defined points
|
||||
|
||||
// Soft outer glow so the star separates from the night background.
|
||||
const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35)
|
||||
glow.addColorStop(0, rgba(base, 0.35))
|
||||
glow.addColorStop(1, rgba(base, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, R * 1.35, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Five-point star path (start at the top point).
|
||||
const star = () => {
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ang = -Math.PI / 2 + (i * Math.PI) / 5
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
g.closePath()
|
||||
}
|
||||
|
||||
// Candy fill: bright near the top-left, deepening toward the lower edge.
|
||||
const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1)
|
||||
fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1))
|
||||
fill.addColorStop(0.5, rgba(base, 1))
|
||||
fill.addColorStop(1, rgba(scale(base, 0.82), 1))
|
||||
star()
|
||||
g.fillStyle = fill
|
||||
g.fill()
|
||||
|
||||
// Puffy outline in a deeper shade of the same hue (round joins soften the
|
||||
// points so it feels hand-drawn, not spiky).
|
||||
g.lineJoin = 'round'
|
||||
g.lineCap = 'round'
|
||||
g.lineWidth = s * 0.05
|
||||
g.strokeStyle = rgba(scale(base, 0.5), 0.95)
|
||||
star()
|
||||
g.stroke()
|
||||
|
||||
// Glossy shine: a small soft white blob up in the top-left lobe.
|
||||
const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3)
|
||||
shine.addColorStop(0, 'rgba(255,255,255,0.85)')
|
||||
shine.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
g.fillStyle = shine
|
||||
g.beginPath()
|
||||
g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
interface Petal {
|
||||
baseX: number
|
||||
y: number
|
||||
@@ -81,7 +210,7 @@ interface Petal {
|
||||
sprite: number
|
||||
}
|
||||
|
||||
export function PetalFall() {
|
||||
export function PetalFall({ night = false }: { night?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +227,12 @@ export function PetalFall() {
|
||||
const canvas = el
|
||||
const ctx = c2d
|
||||
|
||||
const sprites = PALETTE.map(makeSprite)
|
||||
// Night sky = chunky cartoon stars (the stars of the show) with a couple of
|
||||
// small twinkle sparkles mixed in for depth. Random sprite pick weights it
|
||||
// ~70% chunky stars.
|
||||
const sprites = night
|
||||
? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)]
|
||||
: PALETTE.map(makeSprite)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
let W = window.innerWidth
|
||||
let H = window.innerHeight
|
||||
@@ -107,19 +241,39 @@ export function PetalFall() {
|
||||
const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
|
||||
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
const spawn = (initial: boolean): Petal => ({
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(16, 38),
|
||||
vy: rnd(22, 52),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
spin: rnd(-0.9, 0.9),
|
||||
swayAmp: rnd(14, 42),
|
||||
swayFreq: rnd(0.3, 0.85),
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.5, 0.82),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
})
|
||||
// Stars are smaller, drift down a touch slower, sway less, and barely spin
|
||||
// (they twinkle in place instead of tumbling like petals).
|
||||
const spawn = (initial: boolean): Petal =>
|
||||
night
|
||||
? {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(13, 32),
|
||||
vy: rnd(14, 36),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
// Every star visibly spins — random direction, and a guaranteed
|
||||
// minimum speed (a 5-point star is symmetric every 72°, so a slow
|
||||
// rate reads as motionless). Varied so no two turn quite alike.
|
||||
spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2),
|
||||
swayAmp: 0, // stars fall straight down — no petal-like drift
|
||||
swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.55, 0.9),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
: {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(16, 38),
|
||||
vy: rnd(22, 52),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
spin: rnd(-0.9, 0.9),
|
||||
swayAmp: rnd(14, 42),
|
||||
swayFreq: rnd(0.3, 0.85),
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.5, 0.82),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
|
||||
let petals = Array.from({ length: count }, () => spawn(true))
|
||||
|
||||
@@ -157,10 +311,16 @@ export function PetalFall() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Stars shimmer gently (shallow pulse so the chunky ones glow rather
|
||||
// than blink); petals hold a steady alpha.
|
||||
const alpha = night
|
||||
? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase))
|
||||
: p.alpha
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, p.y)
|
||||
ctx.rotate(p.angle)
|
||||
ctx.globalAlpha = p.alpha
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
|
||||
ctx.restore()
|
||||
}
|
||||
@@ -187,7 +347,7 @@ export function PetalFall() {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
petals = []
|
||||
}
|
||||
}, [])
|
||||
}, [night])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
25
web/src/hooks/useNightMode.ts
Normal file
25
web/src/hooks/useNightMode.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isBedtime } from '../lib/night'
|
||||
|
||||
// Tracks whether it's late enough to shift Petal into its calm night mode (dark
|
||||
// theme + falling stars instead of petals). Re-checks once a minute so the swap
|
||||
// happens on its own as the clock rolls past 11pm — no reload needed. Returns
|
||||
// the boolean and also toggles a `petal-night` class on <html> so the CSS token
|
||||
// overrides cascade to everything (body background, panels, portals included).
|
||||
export function useNightMode(): boolean {
|
||||
const [night, setNight] = useState(() => isBedtime())
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setNight(isBedtime())
|
||||
tick()
|
||||
const id = setInterval(tick, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('petal-night', night)
|
||||
return () => document.documentElement.classList.remove('petal-night')
|
||||
}, [night])
|
||||
|
||||
return night
|
||||
}
|
||||
@@ -37,6 +37,23 @@
|
||||
--shadow-soft: 0 4px 20px rgba(180, 130, 160, 0.12);
|
||||
}
|
||||
|
||||
/* Night mode — toggled by adding `petal-night` to <html> (useNightMode) once the
|
||||
clock passes ~11pm. We only re-point the palette tokens; every Tailwind color
|
||||
utility reads them via var(), so the whole UI shifts to a calm, dim, moonlit
|
||||
theme without touching component markup. Pastel accent/type colors are kept —
|
||||
they read beautifully on the dark plum ground. Falling petals become stars. */
|
||||
html.petal-night {
|
||||
--color-bg: #14111E; /* deep night plum-indigo */
|
||||
--color-surface: #211C30; /* raised panels */
|
||||
--color-surface-alt: #2A2440; /* alt surface / hover wash */
|
||||
--color-border: #38304E; /* dim lavender border */
|
||||
--color-plum: #ECE3F2; /* ink → soft moonlit lavender-white */
|
||||
--color-muted: #9F93B8; /* muted lavender-grey, lifted for contrast */
|
||||
--color-accent: #E8A0BF; /* rose still primary — pops on dark */
|
||||
--color-accent-hover: #F2B7D2;/* lighter on hover against the dark */
|
||||
--shadow-soft: 0 6px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -47,6 +64,8 @@ body {
|
||||
color: var(--color-plum);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Gentle dusk/dawn fade when night mode flips. */
|
||||
transition: background 600ms ease, color 600ms ease;
|
||||
}
|
||||
|
||||
/* Gentle, consistent motion across interactive elements */
|
||||
|
||||
11
web/src/lib/night.ts
Normal file
11
web/src/lib/night.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shared "is it late?" definition, used by both the companion's bedtime nag and
|
||||
// the night-mode theme/starfall switch so they always agree. Local clock — the
|
||||
// writer's own machine (millenia's timezone in deployment).
|
||||
|
||||
export const BEDTIME_FROM = 23 // local hour the night window opens (11pm)…
|
||||
export const BEDTIME_TO = 4 // …and closes (4am); past this we assume an early start
|
||||
|
||||
export function isBedtime(d: Date = new Date()): boolean {
|
||||
const h = d.getHours()
|
||||
return h >= BEDTIME_FROM || h < BEDTIME_TO
|
||||
}
|
||||
Reference in New Issue
Block a user