1.0
This commit is contained in:
+103
@@ -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
@@ -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" />
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user