Skip to content
11 changes: 11 additions & 0 deletions src/ThemeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import * as Halloween from './themes/Halloween'
import * as Jonathan from './themes/Jonathan'
import { dark as PastelDark, light as PastelLight } from './themes/Pastel'
import { dark as RainbowDark, light as RainbowLight } from './themes/Rainbow'
import * as Valentines from './themes/Valentines'
import * as Winter from './themes/Winter'

const themes: ITheme[] = [
Winter,
Halloween,
Valentines,
DefaultLight,
DefaultDark,
GradientLight,
Expand Down Expand Up @@ -44,6 +46,14 @@ export default class ThemeManager {
}
}

private isDebugMode (): boolean {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is useful but can we separate it into another PR? I think we should actually solve this with the existing enableDevMode so that you can mock the time and see the themes available. This would let you E2E test that your theme appears on the right dates too.

Right now this is how I mock the time in the JS console:

bellTimer.correctedDate.enableDevMode(new Date('2026-02-16 9:20'), 0)

We should update the theme available function in the seasonal themes to take the actual bellTimer instance instead of using new Date().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, didn't see this in the codebase. I'll remove the debug mode.

// add a debug parameter so that developers can test seasonal themes (amah853)
if (typeof window === 'undefined') return false
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const urlParams = new URLSearchParams(window.location.search)
return isLocalhost && urlParams.get('mode') === 'debug'
}

get defaultTheme (): ITheme {
// The first available theme in the array is the default
for (const theme of themes) {
Expand Down Expand Up @@ -72,6 +82,7 @@ export default class ThemeManager {
}

public isAvailable (themeName: string): boolean {
if (this.isDebugMode()) return true // show all themes if in debug
return !this.themes[themeName].enabled
|| this.themes[themeName].enabled!(this.secrets)
}
Expand Down
207 changes: 207 additions & 0 deletions src/themes/Valentines.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
const BasicColorTheme = require('./templates/BasicColorTheme')
const ColorSchemeTransformations = require('./ColorSchemeTransformations')
const BasicTiming = require('./timings/BasicTiming')

const hearts = new Set()

function drawHeart(ctx, x, y, size, rotation) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(rotation)
ctx.beginPath()
const topCurveHeight = size * 0.3
ctx.moveTo(0, topCurveHeight)
// Left curve
ctx.bezierCurveTo(
0, 0,
-size / 2, 0,
-size / 2, topCurveHeight
)
ctx.bezierCurveTo(
-size / 2, (topCurveHeight + size) / 2,
0, (topCurveHeight + size) / 2,
0, size
)
// Right curve
ctx.bezierCurveTo(
0, (topCurveHeight + size) / 2,
size / 2, (topCurveHeight + size) / 2,
size / 2, topCurveHeight
)
ctx.bezierCurveTo(
size / 2, 0,
0, 0,
0, topCurveHeight
)
ctx.closePath()
ctx.restore()
}

// Helper function to draw a heart path (reusable for both background and foreground)
function drawHeartPath(ctx, w, h, yOffset) {
ctx.beginPath()
ctx.moveTo(0, h * 0.45 + yOffset)
ctx.bezierCurveTo(
-w * 0.4, h * 0.15 + yOffset,
-w, h * 0.05 + yOffset,
-w, -h * 0.15 + yOffset
)
ctx.bezierCurveTo(
-w, -h * 0.42 + yOffset,
-w * 0.5, -h * 0.5 + yOffset,
0, -h * 0.3 + yOffset
)
ctx.bezierCurveTo(
w * 0.5, -h * 0.5 + yOffset,
w, -h * 0.42 + yOffset,
w, -h * 0.15 + yOffset
)
ctx.bezierCurveTo(
w, h * 0.05 + yOffset,
w * 0.4, h * 0.15 + yOffset,
0, h * 0.45 + yOffset
)
ctx.closePath()
}

function drawHeartCountdown(ctx, x, y, radius, proportion) {
const heartWidth = radius * 2
const heartHeight = radius * 2
const w = heartWidth * 0.5
const h = heartHeight * 0.95
const yOffset = h * 0.02
// ===== DRAW BACKGROUND HEART (so they can see that its supposed to be a heart) =====
ctx.save()
ctx.translate(x, y)
ctx.globalAlpha = 0.5
drawHeartPath(ctx, w, h, yOffset)
ctx.fill()
ctx.restore()
// ===== DRAW FOREGROUND HEART (countdown, clipped) =====
ctx.save()
ctx.beginPath()
ctx.arc(x, y, radius * 1.2, (Math.PI / -2), (Math.PI / -2) + (-2 * Math.PI) * (1 - proportion), true)
ctx.lineTo(x, y)
ctx.closePath()
ctx.clip()
ctx.globalAlpha = 0.98
ctx.fillStyle = 'rgba(255,255,255,0.98)'
ctx.translate(x, y)
drawHeartPath(ctx, w, h, yOffset)
ctx.fill()
ctx.restore()
}

module.exports = {
name: 'Valentines',

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "Valentine's"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix!

drawHeartCountdown: drawHeartCountdown,
enabled: (secrets) => {
const now = new Date()
return (now.getMonth() + 1) === 2 // February

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect Valentine's day theme to be only on Valentine's day

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe if Valentine's day is on the weekend we could do the week leading up to it. Continuing to show Valentine's day after it has passed feels odd.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I also want users to see the theme for more then one day? How about Feb 9-Feb 19? That gives them a 10 day window with Valentine's being directly in the middle of that.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about 10 days ending on Valentine's day? That way it won't continue occurring after Valentines' day which I think is odd.

That would also match the behavior of the Halloween theme well: https://github.com/nicolaschan/bell/blob/6727149a96c7d0095b74c06cdc2deaa048dedc63/src/themes/Halloween.js#L9C1-L9C63

},
specialEffects: (ctx, canvas) => {
if (hearts.size < 30 && Math.random() < 0.08) {
hearts.add({
x: Math.random() * window.innerWidth,
y: -20,
size: 30 + Math.random() * 30,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we do about hearts covering up the text, especially on mobile where the screen is small but the hearts are the same size as on desktop?

Image

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk, I thought this looked nice but I can see how it might be intrusive. I'll model the hearts to be underneath the text.

rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.05,
xSpeed: (Math.random() - 0.5) * 1.5,
ySpeed: 1 + Math.random() * 2,
color: Math.random() > 0.5 ? '#ff006e' : '#ff4d6d',
opacity: 0.6 + Math.random() * 0.4,
clicked: false,
pulsePhase: Math.random() * Math.PI * 2
})
}

for (const heart of hearts) {
ctx.globalAlpha = heart.opacity
ctx.fillStyle = heart.clicked ? '#ffffff' : heart.color

// Calculate pulse scale (subtle pulse between 0.9 and 1.1)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to comment obvious code.

Such comments don't add much value and tend to get out of date with changes, which I think this one already is because the next line looks like it will vary between 0.7 and 1.3 not 0.9 and 1.1.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's because I changed the pulse scale later and forget to fix the comment. Regardless, I'll remove the comment.

const pulseScale = 1 + Math.sin(heart.pulsePhase) * 0.3
const currentSize = heart.clicked ? heart.size * 1.5 : heart.size

drawHeart(ctx, heart.x, heart.y, currentSize * pulseScale, heart.rotation)
ctx.fill()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be part of drawing the heart in drawHeart?


if (heart.y > window.innerHeight + 50) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the magic + 50 from? If it's important, let's give it a variable name.

hearts.delete(heart)
}

heart.y += heart.ySpeed
heart.x += heart.xSpeed
heart.rotation += heart.rotationSpeed
heart.pulsePhase += 0.05
}
ctx.globalAlpha = 1.0

canvas.addEventListener('mousedown', (e) => {
for (const heart of hearts) {
const distance = Math.sqrt(Math.pow(heart.x - e.x, 2) + Math.pow(heart.y - e.y, 2))
if (distance < heart.size) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying this out I noticed that the hitbox doesn't match the heart size. When I clicked large hearts near the edge but still within the heart it didn't register.

Looking at this code I think that's because heart.size is just the base size but it may be scaled upon render to appear larger than the heart.size.

heart.clicked = !heart.clicked
break
}
}
})
},
theme: BasicColorTheme(
ColorSchemeTransformations.fromObjectStrings,
BasicTiming,
[{

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these color choices have very low contrast which makes it hard to read, especially if you have poor eyesight, bad lighting, or a bad screen.

Can we check each color combination and ensure all of the text on all of the combinations are easily readable?

You can use this in the JS console to mock the time to check them:

bellTimer.correctedDate.enableDevMode(new Date('2026-02-16 9:21'), 0) 
Image

background: {
'background-image': 'linear-gradient(135deg, #ff6b9d 0%, #ffc3e0 100%)',
'background-color': '#ff85b3'
},
text: '#8b0028',
subtext: '#c41e5c',
contrast: 'white'
}, {
background: {
'background-image': 'linear-gradient(135deg, #ff1f5a 0%, #ff6b9d 100%)',
'background-color': '#ff4578'
},
text: '#8b0028',
subtext: '#c41e5c',
contrast: 'white'
}, {
background: {
'background-image': 'linear-gradient(135deg, #c9184a 0%, #ff4d6d 100%)',
'background-color': '#e6335b'
},
text: '#ffffff',
subtext: '#ffc3e0',
contrast: 'white'
}, {
background: {
'background-image': 'linear-gradient(135deg, #ff4d6d 0%, #ff758f 100%)',
'background-color': '#ff617e'
},
text: '#ffffff',
subtext: '#ffc3e0',
contrast: 'white'
}], {
holiday: {
background: {
'background-image': 'linear-gradient(135deg, #ff006e 0%, #ff85b3 100%)',
'background-color': '#ff4390'
},
text: '#ffffff',
subtext: '#ffc3e0',
contrast: 'white'
},
weekend: {
background: {
'background-image': 'linear-gradient(135deg, #ff006e 0%, #ff85b3 100%)',
'background-color': '#ff4390'
},
text: '#ffffff',
subtext: '#ffc3e0',
contrast: 'white'
}
}
)
}
18 changes: 13 additions & 5 deletions src/ui/Page1.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const m = require('mithril')
const Valentines = require('../themes/Valentines') // To replace the circle with a heart on Valentines theme

var getIconImage = function (min) {
var faviconColors = {
Expand Down Expand Up @@ -79,11 +80,18 @@ var updateGraphics = function (vnode) {
var posX = width / 2
var posY = height / 2

ctx.beginPath()
ctx.arc(posX, posY, radius, (Math.PI / -2), (Math.PI / -2) + (-2 * Math.PI) * (1 - proportion), true)
ctx.lineTo(posX, posY)
ctx.closePath()
ctx.fill()
// Check if current theme is Valentines
if (themeManager.currentTheme.name === 'Valentines') {
// Draw heart countdown instead of circle - same size and behavior
Valentines.drawHeartCountdown(ctx, posX, posY, radius, proportion)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the creativity but there are a few problems with this that make me think we should keep the circle.

  • With a circle, the angle matches the area. If there's 10% of the time remaining you see 10% of the circle's area. With a heart, that no longer holds so at a glance the proportion of time remaining is misleading, especially near the beginning/end of the period.
  • The heart outline results in poor contrast (see the screenshot). I'm straining my eyes trying to make it out against the background and also read the text layered on top of it.
  • This isn't the right place to put the heart draw code. The UI shouldn't depend directly on a concrete theme. Instead if we were to do this, we should abstract out a common interface for the themes so we don't have to special case a theme in the UI rendering code.
Image

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I see how that might be hard to read when the theme gets darker. I'll change it back to a circle, it was more of a last-minute idea and not really that important to me.

} else {
// Draw the normal circle countdown
ctx.beginPath()
ctx.arc(posX, posY, radius, (Math.PI / -2), (Math.PI / -2) + (-2 * Math.PI) * (1 - proportion), true)
ctx.lineTo(posX, posY)
ctx.closePath()
ctx.fill()
}
}
const updateSnowflakes = function (vnode) {
const c = vnode.dom
Expand Down
Loading