This commit is contained in:
h202-wq
2026-08-18 18:20:27 -04:00
commit 9796ddeac3
15 changed files with 1695 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react'
import CandleCanvas from './Candle'
import { daySeed, loadKJV, msUntilMidnightUtc, pickForDay, type Verse } from './verse'
type Phase = 'sealed' | 'revealing' | 'revealed'
function formatCountdown(ms: number): string {
const s = Math.max(0, Math.ceil(ms / 1000))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
return `${String(h).padStart(2, '0')}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`
}
export default function App() {
const [phase, setPhase] = useState<Phase>('sealed')
const [verse, setVerse] = useState<Verse | null>(null)
const [countdown, setCountdown] = useState('')
const [error, setError] = useState(false)
useEffect(() => {
const update = () => setCountdown(formatCountdown(msUntilMidnightUtc()))
update()
const id = window.setInterval(update, 1000)
return () => window.clearInterval(id)
}, [])
const seek = useCallback(async () => {
if (phase !== 'sealed') return
setPhase('revealing')
try {
const verses = await loadKJV()
setVerse(pickForDay(verses, daySeed()))
setPhase('revealed')
} catch {
setError(true)
setPhase('sealed')
}
}, [phase])
const today = new Date().toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<div className="app">
<CandleCanvas />
<div className="noise" aria-hidden="true" />
<div className="vignette" aria-hidden="true" />
<header className="app-header">
<h1 className="title">Walk By Faith</h1>
<p className="tagline">
For we walk by faith, not by sight. <span className="tagline-ref"> 2 Corinthians 5:7</span>
</p>
</header>
<main className="app-main">
{phase !== 'revealed' && (
<section className="sealed" aria-label="Today's verse is sealed">
<div className="seal">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 1.8C12 1.8 4.6 9.4 4.6 15a7.4 7.4 0 0 0 14.8 0c0-5.6-7.4-13.2-7.4-13.2z"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
/>
<path
d="M12 8.4c0 0-3 4.2-3 7a3 3 0 0 0 6 0c0-2.8-3-7-3-7z"
fill="currentColor"
/>
</svg>
</div>
<p className="seal-caption">The verse for this day is sealed</p>
<button className="seek-btn" onClick={seek} disabled={phase === 'revealing'}>
{error ? 'Relight the lamp' : phase === 'revealing' ? 'Lighting the lamp…' : 'Seek the verse'}
</button>
</section>
)}
{phase === 'revealed' && verse && (
<section className="revealed" aria-live="polite">
<p className="reveal-label">The verse for {today}</p>
<div className="verse">
<p className="verse-text">{verse.text}</p>
<p className="verse-ref"> {verse.reference}</p>
</div>
</section>
)}
</main>
<footer className="app-footer">
<p className="countdown">
A new verse is appointed in <strong>{countdown}</strong>
</p>
<p className="foot-note">King James Version · Public Domain · 31,102 verses</p>
</footer>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef } from 'react'
interface Ember {
x: number
y: number
vx: number
vy: number
r: number
life: number
maxLife: number
drift: number
}
export default function CandleCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
let w = 0
let h = 0
let dpr = 1
let raf = 0
const embers: Ember[] = []
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2)
w = window.innerWidth
h = window.innerHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
window.addEventListener('resize', resize)
const rand = (a: number, b: number) => a + Math.random() * (b - a)
const cx = () => w / 2
const flameBaseY = () => h * 0.78
const flameH = () => 84 * Math.min(1, w / 760) + 26
const flameW = () => flameH() * 0.52
const flameTipY = () => flameBaseY() - flameH()
const spawnEmber = () => {
embers.push({
x: cx() + rand(-flameW() * 0.5, flameW() * 0.5),
y: flameTipY() + rand(0, 8),
vx: rand(-0.3, 0.3),
vy: rand(-0.75, -0.4),
r: rand(0.7, 1.9),
life: 0,
maxLife: rand(240, 520),
drift: rand(0, Math.PI * 2),
})
}
const drawGlow = (t: number) => {
const gx = cx()
const gy = flameBaseY() - flameH() * 0.4
const pulse = 0.5 + 0.5 * Math.sin(t * 0.0013)
const radius = Math.max(w, h) * 0.5
const glow = ctx.createRadialGradient(gx, gy, 10, gx, gy, radius)
glow.addColorStop(0, `rgba(255, 168, 82, ${0.15 + 0.05 * pulse})`)
glow.addColorStop(0.4, `rgba(200, 110, 45, ${0.055 + 0.02 * pulse})`)
glow.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = glow
ctx.fillRect(0, 0, w, h)
}
const drawWax = () => {
const x = cx()
const top = flameBaseY() + 10
const bottom = h + 40
const half = 30 * Math.min(1, w / 760) + 12
const left = x - half
const right = x + half
const body = ctx.createLinearGradient(left, 0, right, 0)
body.addColorStop(0, '#d9c9a4')
body.addColorStop(0.5, '#f2e6c8')
body.addColorStop(1, '#cbbb94')
ctx.fillStyle = body
ctx.beginPath()
ctx.moveTo(left, top)
ctx.lineTo(right, top)
ctx.lineTo(right, bottom)
ctx.lineTo(left, bottom)
ctx.closePath()
ctx.fill()
const drop = ctx.createLinearGradient(left, 0, right, 0)
drop.addColorStop(0, '#d9c9a4')
drop.addColorStop(1, '#cbbb94')
ctx.fillStyle = drop
for (let i = 0; i < 3; i++) {
const dx = left - 5 + i * 5
const dy = top + 14 + i * 22
const len = 26 + i * 9
ctx.beginPath()
ctx.moveTo(dx, dy - 12)
ctx.quadraticCurveTo(dx + 4, dy, dx + 4, dy + len)
ctx.quadraticCurveTo(dx + 4, dy + len + 7, dx, dy + len + 7)
ctx.quadraticCurveTo(dx - 4, dy + len + 7, dx - 4, dy + len)
ctx.quadraticCurveTo(dx - 4, dy, dx, dy - 12)
ctx.closePath()
ctx.fill()
}
const topEdge = ctx.createLinearGradient(0, top, 0, top + 10)
topEdge.addColorStop(0, '#fdf3da')
topEdge.addColorStop(1, 'rgba(253, 243, 218, 0)')
ctx.fillStyle = topEdge
ctx.fillRect(left, top - 2, half * 2, 10)
ctx.strokeStyle = '#2b2017'
ctx.lineWidth = 2.5
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(x, flameBaseY())
ctx.lineTo(x, top)
ctx.stroke()
}
const drawFlame = (t: number) => {
const fh = flameH() * (1 + 0.09 * Math.sin(t * 0.011) + 0.045 * Math.sin(t * 0.031 + 1.7))
const fw = flameW() * (1 + 0.08 * Math.sin(t * 0.017 + 0.6) + 0.04 * Math.sin(t * 0.043))
const tipX = cx()
const tipY = flameTipY() - (fh - flameH()) * 0.6
const baseY = tipY + fh
const outer = ctx.createRadialGradient(tipX, baseY - fh * 0.45, 2, tipX, baseY - fh * 0.5, fw * 2.2)
outer.addColorStop(0, 'rgba(255, 244, 214, 0.95)')
outer.addColorStop(0.35, 'rgba(255, 205, 120, 0.85)')
outer.addColorStop(0.7, 'rgba(240, 140, 60, 0.45)')
outer.addColorStop(1, 'rgba(240, 120, 40, 0)')
ctx.save()
ctx.translate(tipX, baseY)
ctx.scale(1, -1)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.quadraticCurveTo(fw * 0.75, fh * 0.15, fw * 0.34, fh * 0.72)
ctx.quadraticCurveTo(fw * 0.12, fh * 0.97, 0, fh)
ctx.quadraticCurveTo(-fw * 0.12, fh * 0.97, -fw * 0.34, fh * 0.72)
ctx.quadraticCurveTo(-fw * 0.75, fh * 0.15, 0, 0)
ctx.closePath()
ctx.fillStyle = outer
ctx.fill()
const ih = fh * 0.42
const iw = fw * 0.4
ctx.beginPath()
ctx.moveTo(0, fh * 0.16)
ctx.quadraticCurveTo(iw * 0.7, fh * 0.16 + ih * 0.28, iw * 0.3, fh * 0.16 + ih * 0.82)
ctx.quadraticCurveTo(iw * 0.1, fh * 0.16 + ih * 1.05, 0, fh * 0.16 + ih)
ctx.quadraticCurveTo(-iw * 0.1, fh * 0.16 + ih * 1.05, -iw * 0.3, fh * 0.16 + ih * 0.82)
ctx.quadraticCurveTo(-iw * 0.7, fh * 0.16 + ih * 0.28, 0, fh * 0.16)
ctx.closePath()
const inner = ctx.createRadialGradient(0, fh * 0.55, 1, 0, fh * 0.58, iw * 1.4)
inner.addColorStop(0, 'rgba(255, 252, 240, 1)')
inner.addColorStop(0.6, 'rgba(255, 226, 160, 0.9)')
inner.addColorStop(1, 'rgba(255, 190, 90, 0)')
ctx.fillStyle = inner
ctx.fill()
ctx.restore()
}
const drawEmbers = () => {
ctx.globalCompositeOperation = 'lighter'
for (let i = embers.length - 1; i >= 0; i--) {
const e = embers[i]
e.life += 1
e.x += e.vx + Math.sin(e.life * 0.02 + e.drift) * 0.3
e.y += e.vy
if (e.life >= e.maxLife || e.y < -30) {
embers.splice(i, 1)
continue
}
const alpha = Math.max(0, 1 - e.life / e.maxLife)
const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.r * 3.2)
grad.addColorStop(0, `rgba(255, 214, 140, ${alpha})`)
grad.addColorStop(0.5, `rgba(240, 150, 70, ${alpha * 0.6})`)
grad.addColorStop(1, 'rgba(240, 120, 40, 0)')
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(e.x, e.y, e.r * 3.2, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalCompositeOperation = 'source-over'
}
const draw = (t: number) => {
ctx.clearRect(0, 0, w, h)
drawGlow(t)
drawWax()
drawFlame(t)
if (!reduced) {
if (embers.length < 70 && Math.random() < 0.14) spawnEmber()
drawEmbers()
}
}
const loop = (t: number) => {
draw(t)
raf = requestAnimationFrame(loop)
}
if (reduced) {
draw(0)
} else {
raf = requestAnimationFrame(loop)
}
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
}
}, [])
return <canvas ref={canvasRef} className="candle-canvas" aria-hidden="true" />
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './style.css'
import App from './App'
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+275
View File
@@ -0,0 +1,275 @@
:root {
--bg: #0b0806;
--ink: #ecd9b4;
--ink-dim: #a08a68;
--ink-faint: rgba(160, 138, 104, 0.55);
--accent: #f0a94f;
--accent-soft: #ffd9a0;
--line: rgba(240, 169, 79, 0.28);
--serif: 'EB Garamond', Georgia, 'Times New Roman', serif;
--display: 'Cinzel', 'EB Garamond', Georgia, serif;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
.candle-canvas {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
.noise {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.05;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.7'/></svg>");
}
.vignette {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(ellipse at 50% 40%, transparent 32%, rgba(0, 0, 0, 0.5) 72%, rgba(0, 0, 0, 0.86) 100%);
}
.app {
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: clamp(1.5rem, 4vh, 3rem) clamp(1.25rem, 5vw, 3rem);
text-align: center;
}
.app-header {
width: 100%;
}
.title {
font-family: var(--display);
text-transform: uppercase;
letter-spacing: 0.42em;
text-indent: 0.42em;
font-weight: 600;
font-size: clamp(1.15rem, 3vw, 1.7rem);
color: var(--ink);
margin: 0;
}
.tagline {
margin: 1rem 0 0;
font-style: italic;
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--ink-dim);
}
.tagline-ref {
font-style: normal;
font-family: var(--display);
letter-spacing: 0.14em;
font-size: 0.78em;
color: var(--ink-faint);
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding: 2.5rem 0;
}
.seal {
width: clamp(10rem, 26vw, 13rem);
aspect-ratio: 1;
border-radius: 50%;
border: 1px solid var(--line);
color: var(--accent);
box-shadow:
0 0 0 8px rgba(240, 169, 79, 0.05),
0 0 0 9px rgba(240, 169, 79, 0.16) inset,
0 0 60px rgba(240, 169, 79, 0.12);
display: grid;
place-items: center;
background: radial-gradient(circle at 50% 40%, rgba(240, 169, 79, 0.09), rgba(0, 0, 0, 0) 70%);
}
.seal svg {
width: 34%;
height: 34%;
opacity: 0.85;
}
.seal-caption {
margin: 1.6rem 0 0;
font-family: var(--display);
letter-spacing: 0.28em;
text-indent: 0.28em;
text-transform: uppercase;
font-size: 0.72rem;
color: var(--ink-dim);
}
.seek-btn {
margin-top: 2.2rem;
font-family: var(--display);
text-transform: uppercase;
letter-spacing: 0.3em;
text-indent: 0.3em;
font-size: 0.82rem;
color: var(--accent-soft);
background: transparent;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.95rem 2.6rem;
cursor: pointer;
animation: seal-breathe 3.2s ease-in-out infinite;
transition:
box-shadow 0.5s,
background 0.5s,
border-color 0.5s,
color 0.5s;
}
.seek-btn:hover {
box-shadow:
0 0 24px rgba(240, 169, 79, 0.35),
inset 0 0 18px rgba(240, 169, 79, 0.12);
background: rgba(240, 169, 79, 0.06);
border-color: rgba(240, 169, 79, 0.55);
color: #fff0d4;
}
.seek-btn:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 4px;
}
.seek-btn:disabled {
opacity: 0.65;
cursor: wait;
}
@keyframes seal-breathe {
0%,
100% {
box-shadow: 0 0 8px rgba(240, 169, 79, 0.12);
}
50% {
box-shadow: 0 0 26px rgba(240, 169, 79, 0.3);
}
}
.reveal-label {
font-family: var(--display);
letter-spacing: 0.3em;
text-indent: 0.3em;
text-transform: uppercase;
font-size: 0.68rem;
color: var(--ink-dim);
margin: 0 0 1.8rem;
}
.verse {
max-width: 46rem;
margin: 0 auto;
animation: rise 1.6s ease-out both;
}
.verse-text {
font-size: clamp(1.35rem, 4.2vw, 2.3rem);
line-height: 1.55;
color: var(--accent-soft);
font-style: italic;
margin: 0;
}
.verse-text::first-letter {
font-size: 2.6em;
float: left;
line-height: 0.9;
padding-right: 0.12em;
color: var(--accent);
font-style: normal;
}
.verse-ref {
margin: 1.6rem 0 0;
font-family: var(--display);
letter-spacing: 0.18em;
text-indent: 0.18em;
color: var(--accent);
font-size: 0.9rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
filter: blur(6px);
}
to {
opacity: 1;
transform: none;
filter: none;
}
}
.app-footer {
width: 100%;
}
.countdown {
margin: 0;
font-size: 0.85rem;
color: var(--ink-dim);
letter-spacing: 0.06em;
}
.countdown strong {
color: var(--accent-soft);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.foot-note {
margin: 0.6rem 0 0;
font-size: 0.7rem;
letter-spacing: 0.18em;
text-indent: 0.18em;
text-transform: uppercase;
color: var(--ink-faint);
}
@media (prefers-reduced-motion: reduce) {
.seek-btn,
.verse {
animation: none;
}
}
+69
View File
@@ -0,0 +1,69 @@
export interface KJVVerseData {
verse: number
text: string
}
export interface KJVChapterData {
chapter: string
verses: KJVVerseData[]
}
export interface KJVBookData {
book: string
chapters: KJVChapterData[]
}
export interface KJVData {
books: KJVBookData[]
}
export interface Verse {
text: string
reference: string
}
let flat: Verse[] | null = null
export function daySeed(now: Date = new Date()): number {
return Math.floor(now.getTime() / 86_400_000)
}
export function msUntilMidnightUtc(now: Date = new Date()): number {
const next = new Date(now)
next.setUTCHours(24, 0, 0, 0)
return next.getTime() - now.getTime()
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export function pickForDay(verses: Verse[], seed: number): Verse {
const rand = mulberry32(seed)
const index = Math.floor(rand() * verses.length)
return verses[index]
}
export async function loadKJV(): Promise<Verse[]> {
if (flat) return flat
const res = await fetch('/kjv.json')
if (!res.ok) throw new Error(`Failed to load scripture (${res.status})`)
const data = (await res.json()) as KJVData
flat = []
for (const book of data.books) {
const name = book.book === 'Psalms' ? 'Psalm' : book.book
for (const chapter of book.chapters) {
const chapterNumber = Number(chapter.chapter)
for (const v of chapter.verses) {
flat.push({ text: v.text, reference: `${name} ${chapterNumber}:${v.verse}` })
}
}
}
return flat
}