@meycult/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ /** MeyCult Unveil background effects — 4 canvas + 6 CSS layers. */
4
+ import { useEffect, useRef } from 'react'
5
+
6
+ export interface FxProps {
7
+ density?: number // 0-100
8
+ speed?: number // 0-2 multiplier
9
+ opacity?: number // 0-1 master
10
+ }
11
+
12
+ function reducedMotion(): boolean {
13
+ return (
14
+ typeof window !== 'undefined' &&
15
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
16
+ )
17
+ }
18
+
19
+ /** Shared rAF canvas loop: DPR cap, resize, hidden-tab + offscreen pause. */
20
+ function useCanvas(
21
+ ref: React.RefObject<HTMLCanvasElement | null>,
22
+ render: (ctx: CanvasRenderingContext2D, w: number, h: number, t: number) => void,
23
+ active: boolean,
24
+ ) {
25
+ useEffect(() => {
26
+ if (!active) return
27
+ const canvas = ref.current
28
+ if (!canvas || reducedMotion()) return
29
+ const ctx = canvas.getContext('2d')
30
+ if (!ctx) return
31
+ let raf = 0
32
+ let w = 0
33
+ let h = 0
34
+ const resize = () => {
35
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
36
+ const rect = canvas.getBoundingClientRect()
37
+ w = Math.max(1, Math.floor(rect.width * dpr))
38
+ h = Math.max(1, Math.floor(rect.height * dpr))
39
+ canvas.width = w
40
+ canvas.height = h
41
+ }
42
+ resize()
43
+ const ro = new ResizeObserver(resize)
44
+ const parent = canvas.parentElement
45
+ if (parent) ro.observe(parent)
46
+ let visible = true
47
+ const io = new IntersectionObserver(([e]) => {
48
+ visible = e.isIntersecting
49
+ if (visible) last = performance.now()
50
+ })
51
+ io.observe(canvas)
52
+ let last = performance.now()
53
+ const loop = (t: number) => {
54
+ raf = requestAnimationFrame(loop)
55
+ if (!visible || document.hidden) {
56
+ last = t
57
+ return
58
+ }
59
+ void last
60
+ last = t
61
+ ctx.clearRect(0, 0, w, h)
62
+ render(ctx, w, h, t)
63
+ }
64
+ raf = requestAnimationFrame(loop)
65
+ return () => {
66
+ cancelAnimationFrame(raf)
67
+ ro.disconnect()
68
+ io.disconnect()
69
+ }
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [active])
72
+ }
73
+
74
+ /**
75
+ * Old-Egypt / illuminati glyph ascension — all cult yellow. Authentic
76
+ * hieroglyphs via self-hosted 'Noto Sans Egyptian Hieroglyphs' (same
77
+ * pattern as New Rocker); serif fallback covers ☥/△/◉ everywhere.
78
+ */
79
+ const GLYPH_FONT = `'"Noto Sans Egyptian Hieroglyphs", serif'`
80
+ const GLYPHS = [
81
+ '𓂀', '𓂀', // Eye of Horus
82
+ '☥', '☥', // ankh — life
83
+ '△', // pyramid
84
+ '𓆣', // scarab
85
+ '𓅓', // owl
86
+ '𓇳', // sun
87
+ '𓊵', // djed pillar
88
+ '𓆙', // cobra
89
+ '𓆄', // feather
90
+ '◉', // the eye
91
+ ]
92
+
93
+ export function RuneCanvas({ density = 50, speed = 0.6, opacity = 0.8 }: FxProps) {
94
+ const ref = useRef<HTMLCanvasElement>(null)
95
+ const parts = useRef(
96
+ Array.from({ length: 60 }, (_, i) => ({
97
+ x: Math.random(),
98
+ y: Math.random(),
99
+ vy: 0.0002 + Math.random() * 0.0006,
100
+ tw: Math.random() * Math.PI * 2,
101
+ size: 10 + Math.random() * 12,
102
+ glyph: GLYPHS[i % GLYPHS.length],
103
+ })),
104
+ )
105
+ useEffect(() => {
106
+ // ensure the hieroglyph webfont is ready before first glyphs drift in
107
+ try {
108
+ void document.fonts?.load(`64px ${GLYPH_FONT}`)
109
+ } catch {
110
+ /* serif fallback covers the safe subset */
111
+ }
112
+ }, [])
113
+ useCanvas(
114
+ ref,
115
+ (ctx, w, h, t) => {
116
+ const n = Math.floor((density / 100) * parts.current.length)
117
+ for (let i = 0; i < n; i++) {
118
+ const p = parts.current[i]
119
+ p.y -= p.vy * (0.4 + speed)
120
+ p.tw += 0.02 * (0.4 + speed)
121
+ if (p.y < -0.05) {
122
+ p.y = 1.05
123
+ p.x = Math.random()
124
+ }
125
+ const x = (p.x + Math.sin(p.tw) * 0.008) * w
126
+ const y = p.y * h
127
+ ctx.globalAlpha = (0.15 + 0.5 * Math.abs(Math.sin(p.tw))) * opacity
128
+ ctx.font = `${p.size}px ${GLYPH_FONT}`
129
+ ctx.fillStyle = '#FFE600'
130
+ ctx.fillText(p.glyph, x, y)
131
+ }
132
+ void t
133
+ ctx.globalAlpha = 1
134
+ },
135
+ true,
136
+ )
137
+ return <canvas ref={ref} className="mey-bg-canvas" aria-hidden="true" />
138
+ }
139
+
140
+ export function AshCanvas({ density = 50, speed = 0.5, opacity = 0.8 }: FxProps) {
141
+ const ref = useRef<HTMLCanvasElement>(null)
142
+ const parts = useRef(
143
+ Array.from({ length: 220 }, () => ({
144
+ x: Math.random(),
145
+ y: Math.random(),
146
+ life: 100 + Math.random() * 200,
147
+ })),
148
+ )
149
+ useCanvas(
150
+ ref,
151
+ (ctx, w, h, t) => {
152
+ // fade previous frame (destination-out keeps canvas transparent)
153
+ ctx.globalCompositeOperation = 'destination-out'
154
+ ctx.fillStyle = 'rgba(0,0,0,0.08)'
155
+ ctx.fillRect(0, 0, w, h)
156
+ ctx.globalCompositeOperation = 'source-over'
157
+ const n = Math.floor((density / 100) * parts.current.length)
158
+ const z = t * 0.0001 * (0.3 + speed)
159
+ for (let i = 0; i < n; i++) {
160
+ const p = parts.current[i]
161
+ const px = p.x * w
162
+ const py = p.y * h
163
+ const ang =
164
+ (Math.sin(px * 0.002 + z * 7) + Math.cos(py * 0.002 - z * 5)) * Math.PI
165
+ const nx = px + Math.cos(ang) * 2
166
+ const ny = py + Math.sin(ang) * 2
167
+ ctx.globalAlpha = 0.25 * opacity
168
+ ctx.strokeStyle = '#FFE600'
169
+ ctx.beginPath()
170
+ ctx.moveTo(px, py)
171
+ ctx.lineTo(nx, ny)
172
+ ctx.stroke()
173
+ p.x = (nx / w + 1) % 1
174
+ p.y = (ny / h + 1) % 1
175
+ if (--p.life <= 0) {
176
+ p.x = Math.random()
177
+ p.y = Math.random()
178
+ p.life = 200
179
+ }
180
+ }
181
+ ctx.globalAlpha = 1
182
+ },
183
+ true,
184
+ )
185
+ return <canvas ref={ref} className="mey-bg-canvas" aria-hidden="true" />
186
+ }
187
+
188
+ export function VeilFog({ opacity = 0.7 }: FxProps) {
189
+ return (
190
+ <div className="mey-veil" style={{ opacity }} aria-hidden="true">
191
+ <i />
192
+ <i />
193
+ </div>
194
+ )
195
+ }
196
+
197
+
198
+ export function BreathVignette() {
199
+ return <div className="mey-breath" aria-hidden="true" />
200
+ }
package/src/unveil.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MeyCult Unveil constants — single source of truth for the countdown.
3
+ * Target: Fri 25 Sept 2026, 20:00 SAST (Africa/Johannesburg, UTC+2, no DST).
4
+ * Overridable via NEXT_PUBLIC_UNVEIL_DATE (ISO with offset) for previews.
5
+ */
6
+
7
+ export const UNVEIL_TZ = 'Africa/Johannesburg'
8
+
9
+ export const UNVEIL_DEFAULT_ISO = '2026-09-25T20:00:00+02:00'
10
+
11
+ /** Live window after T-0 before flipping to replay/ended state. */
12
+ export const LIVE_WINDOW_MS = 3 * 3_600_000
13
+
14
+ function resolveLaunchAt(): number {
15
+ const raw =
16
+ typeof process !== 'undefined'
17
+ ? process.env?.NEXT_PUBLIC_UNVEIL_DATE || process.env?.VITE_UNVEIL_DATE
18
+ : undefined
19
+ const t = Date.parse(raw || UNVEIL_DEFAULT_ISO)
20
+ return Number.isFinite(t) ? t : Date.parse(UNVEIL_DEFAULT_ISO)
21
+ }
22
+
23
+ export const UNVEIL_AT = resolveLaunchAt()
24
+
25
+ export type UnveilPhase = 'countdown' | 'live' | 'ended'
26
+
27
+ export function unveilPhase(now: number = Date.now()): UnveilPhase {
28
+ if (now < UNVEIL_AT) return 'countdown'
29
+ if (now < UNVEIL_AT + LIVE_WINDOW_MS) return 'live'
30
+ return 'ended'
31
+ }
32
+
33
+ /** "25 Sept 2026, 20:00 SAST" — always rendered in Unveil timezone (SAST). */
34
+ export function unveilDayLabel(at: number = UNVEIL_AT): string {
35
+ return new Intl.DateTimeFormat('en-ZA', {
36
+ timeZone: UNVEIL_TZ,
37
+ weekday: 'short',
38
+ day: 'numeric',
39
+ month: 'short',
40
+ year: 'numeric',
41
+ hour: '2-digit',
42
+ minute: '2-digit',
43
+ hour12: false,
44
+ }).format(new Date(at))
45
+ }
46
+
47
+ /** Visitor-local rendering of the same instant. */
48
+ export function unveilLocalLabel(at: number = UNVEIL_AT): string {
49
+ return new Intl.DateTimeFormat(undefined, {
50
+ day: 'numeric',
51
+ month: 'short',
52
+ hour: '2-digit',
53
+ minute: '2-digit',
54
+ timeZoneName: 'short',
55
+ }).format(new Date(at))
56
+ }
57
+
58
+ export interface CountdownParts {
59
+ days: number
60
+ hours: number
61
+ minutes: number
62
+ seconds: number
63
+ done: boolean
64
+ }
65
+
66
+ export function diffParts(target: number, now: number = Date.now()): CountdownParts {
67
+ const delta = target - now
68
+ if (delta <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, done: true }
69
+ return {
70
+ days: Math.floor(delta / 86_400_000),
71
+ hours: Math.floor(delta / 3_600_000) % 24,
72
+ minutes: Math.floor(delta / 60_000) % 60,
73
+ seconds: Math.floor(delta / 1_000) % 60,
74
+ done: false,
75
+ }
76
+ }
77
+
78
+ export const pad2 = (n: number) => String(n).padStart(2, '0')