@meycult/core 0.1.0 → 0.2.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.
Files changed (43) hide show
  1. package/package.json +3 -6
  2. package/src/brand/BrandName.tsx +4 -2
  3. package/src/brand/LaunchButton.tsx +5 -3
  4. package/src/brand/Logo.tsx +25 -6
  5. package/src/brand/PyramidName.tsx +10 -0
  6. package/src/brand/Sigil.tsx +10 -5
  7. package/src/brand/TokenIcons.tsx +12 -6
  8. package/src/brand.ts +4 -1
  9. package/src/components/CultCountdown.tsx +89 -14
  10. package/src/game/categories.ts +23 -0
  11. package/src/game/cults.ts +88 -0
  12. package/src/game/quests.ts +62 -0
  13. package/src/game/types.ts +27 -0
  14. package/src/index.ts +64 -21
  15. package/src/product.ts +17 -0
  16. package/src/styles/theme.css +97 -6
  17. package/src/tokens.ts +10 -2
  18. package/src/ui/CultButton.tsx +55 -0
  19. package/src/ui/RarityBadge.tsx +18 -0
  20. package/src/ui/animated-shiny-text.tsx +2 -2
  21. package/src/ui/badges.tsx +43 -0
  22. package/src/ui/card.tsx +20 -92
  23. package/src/ui/shimmer-button.tsx +5 -3
  24. package/src/ui/virtues.tsx +66 -0
  25. package/src/unveil/LaunchStage.tsx +3 -2
  26. package/src/unveil/bg.ts +20 -11
  27. package/src/unveil/bgStyles.ts +0 -1
  28. package/src/unveil/blocks.tsx +51 -14
  29. package/src/unveil/effects.tsx +182 -86
  30. package/src/unveil.ts +2 -21
  31. package/src/auth/Gate.tsx +0 -14
  32. package/src/auth/types.ts +0 -12
  33. package/src/brand/GodEmperor.tsx +0 -8
  34. package/src/cn.ts +0 -6
  35. package/src/lib/images.ts +0 -10
  36. package/src/lib/money.ts +0 -12
  37. package/src/lib/store.ts +0 -19
  38. package/src/shell/types.ts +0 -13
  39. package/src/supabase/browser.ts +0 -103
  40. package/src/supabase/callback.ts +0 -15
  41. package/src/supabase/proxy.ts +0 -36
  42. package/src/supabase/server.ts +0 -31
  43. package/src/ui/Callout.tsx +0 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meycult/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "MeyCult shared core — yellow theme, brand, supabase/auth factories, lib. Source-only, no build.",
6
6
  "license": "ISC",
@@ -24,11 +24,8 @@
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
26
  "./tokens": "./src/tokens.ts",
27
- "./cn": "./src/cn.ts",
28
27
  "./brand": "./src/brand.ts",
29
- "./supabase/server": "./src/supabase/server.ts",
30
- "./supabase/proxy": "./src/supabase/proxy.ts",
31
- "./supabase/callback": "./src/supabase/callback.ts",
28
+ "./product": "./src/product.ts",
32
29
  "./styles/theme.css": "./src/styles/theme.css"
33
30
  },
34
31
  "scripts": {
@@ -36,7 +33,7 @@
36
33
  "release": "bash scripts/release.sh"
37
34
  },
38
35
  "dependencies": {
39
- "@meyvoid/core": "^0.1.0",
36
+ "@meyvoid/core": "^0.2.0",
40
37
  "clsx": "^2.1.1",
41
38
  "tailwind-merge": "^3.6.0",
42
39
  "zustand": "^5.0.14"
@@ -1,11 +1,13 @@
1
+ import { GLOW } from '../tokens'
2
+
1
3
  export default function BrandName({ className }: { className?: string }) {
2
4
  return (
3
5
  <span className={className} style={{ fontFamily: 'var(--font-logo)', fontWeight: 700, textTransform: 'none' }}>
4
6
  <span className="brand-mey" style={{
5
- textShadow: '0 0 40px rgba(255,255,255,0.35), 0 0 80px rgba(255,255,255,0.15), 0 0 120px rgba(255,255,255,0.08)',
7
+ textShadow: GLOW.brandMey,
6
8
  }}>Mey</span>
7
9
  <span className="brand-cult" style={{
8
- textShadow: '0 0 40px rgba(255,230,0,0.35), 0 0 80px rgba(255,230,0,0.18), 0 0 120px rgba(255,230,0,0.08)',
10
+ textShadow: GLOW.brandCult,
9
11
  }}>Cult</span>
10
12
  </span>
11
13
  )
@@ -1,3 +1,5 @@
1
+ import { GRADIENTS, GLOW } from '../tokens'
2
+
1
3
  interface LaunchButtonProps {
2
4
  label?: string
3
5
  href?: string
@@ -8,14 +10,14 @@ interface LaunchButtonProps {
8
10
  export default function LaunchButton({ label = 'Launch', href = 'https://app.meycult.com', className = '', small }: LaunchButtonProps) {
9
11
  return (
10
12
  <span className={`inline-block rounded-lg p-[2px] hover:scale-[1.02] transition-all ${className}`}
11
- style={{ background: 'linear-gradient(135deg, #FFE600, #FFF7A1)' }}>
13
+ style={{ background: GRADIENTS.launch }}>
12
14
  <a
13
15
  href={href}
14
16
  className={`block font-bold tracking-wider rounded-[6px] hover:opacity-90 transition-all whitespace-nowrap ${small ? 'px-3 py-1.5 text-[10px]' : 'px-8 py-2.5 text-sm'}`}
15
17
  style={{
16
- background: 'linear-gradient(135deg, #0B0A02, #4A3D00)',
18
+ background: GRADIENTS.launchBody,
17
19
  color: '#ffffff',
18
- boxShadow: '0 0 80px rgba(255,230,0,0.08), 0 0 120px rgba(255,230,0,0.05)',
20
+ boxShadow: GLOW.text,
19
21
  }}
20
22
  >
21
23
  <span style={{ fontFamily: 'var(--font-logo)', fontWeight: 700 }}>{label} </span>
@@ -1,24 +1,43 @@
1
+ import type { LinkComponent } from '@meyvoid/core'
2
+ import { BRAND } from '../brand'
1
3
  import Sigil from './Sigil'
2
4
 
3
- export default function Logo({ size = 24, className }: { size?: number; className?: string }) {
5
+ export default function Logo({
6
+ size = 24,
7
+ className,
8
+ href = '/',
9
+ tagline = BRAND.tagline,
10
+ product,
11
+ Link,
12
+ }: {
13
+ size?: number
14
+ className?: string
15
+ href?: string
16
+ tagline?: string
17
+ /** Product lockup (e.g. <PyramidName/>) — replaces the tagline line when set. */
18
+ product?: React.ReactNode
19
+ /** Injectable link — host passes react-router Link (app) or next/link (web). Defaults to <a>. */
20
+ Link?: LinkComponent
21
+ }) {
22
+ const Cmp = (Link ?? 'a') as LinkComponent
4
23
  return (
5
- <a href="/" className={`flex items-center gap-3 ${className || ''}`}>
24
+ <Cmp href={href} className={`flex items-center gap-3 ${className || ''}`}>
6
25
  <Sigil size={size} />
7
26
  <div>
8
27
  <div
9
28
  className="text-lg font-bold tracking-wider leading-none"
10
29
  style={{ fontFamily: 'var(--font-logo)' }}
11
30
  >
12
- <span className="brand-mey">Mey</span>
13
- <span className="brand-cult" style={{ textShadow: '0 0 12px rgba(255,255,255,0.5), 0 0 30px rgba(255,255,255,0.2)' }}>Cult</span>
31
+ <span className="brand-mey">{BRAND.first}</span>
32
+ <span className="brand-cult" style={{ textShadow: '0 0 12px rgba(255,255,255,0.5), 0 0 30px rgba(255,255,255,0.2)' }}>{BRAND.second}</span>
14
33
  </div>
15
34
  <div
16
35
  className="text-[10px] uppercase tracking-widest mt-0.5"
17
36
  style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-logo)' }}
18
37
  >
19
- Wager &amp; Join
38
+ {product ?? tagline}
20
39
  </div>
21
40
  </div>
22
- </a>
41
+ </Cmp>
23
42
  )
24
43
  }
@@ -0,0 +1,10 @@
1
+ import { PRODUCT } from '../product'
2
+
3
+ /** Pyramid wordmark — the game product. Pair with BrandName (MeyCult) above it. */
4
+ export default function PyramidName({ className }: { className?: string }) {
5
+ return (
6
+ <span className={`brand-pyramid ${className ?? ''}`}>
7
+ {PRODUCT.name}
8
+ </span>
9
+ )
10
+ }
@@ -1,26 +1,31 @@
1
+ import { cult as cultSigil } from '@meyvoid/core/sigils'
2
+
3
+ /** Cult mark — geometry is canonical in @meyvoid/core/sigils, styling (accent fill, glow) is cult. */
1
4
  export default function Sigil({ size = 24 }: { size?: number }) {
2
5
  const s = size * 1.5
3
6
 
4
7
  return (
5
8
  <svg
6
9
  width={s}
7
- viewBox="0 0 200.56923 175.72475"
10
+ viewBox={cultSigil.VIEW_BOX}
8
11
  style={{ filter: 'drop-shadow(var(--glow))' }}
9
12
  >
10
13
  <g transform="translate(-341.95552,-53.816747)">
11
14
  <g transform="translate(15.212189,33.321951)" style={{ fill: 'var(--color-accent)' }}>
12
15
  <path
13
16
  style={{ fill: 'var(--color-accent)', fillOpacity: 1 }}
14
- d="m 426.49541,20.513477 c -2.53292,0.177108 -4.81223,1.601364 -6.0818,3.800284 l -52.93268,91.681229 -39.703,68.76789 c -2.93967,5.09188 0.73505,11.45661 6.61458,11.45667 h 105.86485 79.40601 c 5.87953,-6e-5 9.55425,-6.36479 6.61458,-11.45667 l -52.93217,-91.681223 -39.703,-68.767896 c -1.46009,-2.528918 -4.23433,-4.003991 -7.14737,-3.800284 z"
17
+ d={cultSigil.SURROUND_D}
15
18
  />
16
19
  </g>
17
20
  <path
18
21
  style={{ fill: '#ffffff', fillOpacity: 1 }}
19
- d="m 442.44151,112.59785 c 10.89456,-0.0294 21.77374,0.33291 24.9048,1.10024 18.16066,4.45082 35.71071,13.53969 47.98578,24.85243 8.87021,8.17481 15.22133,14.18661 16.21657,15.34937 2.27217,2.65448 3.21391,6.04251 2.29265,8.24739 -0.487,1.16559 -8.37889,9.64325 -17.45502,18.75132 -6.49799,6.52082 -11.77836,9.84655 -23.09636,14.5445 -2.43619,1.01128 -8.55868,3.59204 -13.60505,5.73526 -5.04636,2.14322 -10.71996,4.40195 -12.60866,5.0198 l -13.80921,3.00538 c -3.83618,0.48165 -13.15749,0.64819 -17.24367,0.30803 -7.65437,-0.63718 -17.39937,-2.6625 -24.94182,-5.1824 -14.40439,-4.81246 -26.96098,-11.29515 -36.43673,-18.81095 -2.63565,-2.09048 -13.55541,-12.206 -19.18402,-17.77121 -2.76668,-2.73554 -4.34874,-5.49272 -4.61149,-8.03873 -0.231,-2.2383 0.26498,-3.06562 3.73616,-6.23205 1.59134,-1.45165 4.70534,-4.84775 6.91952,-7.54642 6.74191,-8.21714 9.10658,-10.11388 20.73231,-16.62942 18.29568,-10.25362 23.48708,-12.53282 35.20639,-15.45595 3.19292,-0.79635 14.10326,-1.21717 24.99785,-1.24659 z"
22
+ d={cultSigil.EYE_D}
20
23
  />
21
- <path
24
+ <circle
25
+ cx={cultSigil.PUPIL.cx}
26
+ cy={cultSigil.PUPIL.cy}
27
+ r={cultSigil.PUPIL.r}
22
28
  style={{ fill: 'var(--color-bg)' }}
23
- d="m 442.49623,120.3747 c -22.51598,-4e-5 -40.76826,18.25286 -40.76822,40.76884 -10e-6,22.51597 18.25225,40.76887 40.76822,40.76883 22.51558,-5.3e-4 40.76824,-18.25326 40.76823,-40.76883 3e-5,-22.51557 -18.25265,-40.76832 -40.76823,-40.76884 z"
24
29
  />
25
30
  </g>
26
31
  </svg>
@@ -3,6 +3,8 @@ export interface TokenIconProps {
3
3
  /** Asset base — host's public dir mount. Defaults to '/products'. */
4
4
  basePath?: string
5
5
  className?: string
6
+ /** Render icon only (no wordmark) — app ergonomics. */
7
+ icon?: boolean
6
8
  }
7
9
 
8
10
  export function InfluenceIcon({ size = 14, basePath = '/products', className }: TokenIconProps) {
@@ -27,22 +29,26 @@ export function FateIcon({ size = 14, basePath = '/products', className }: Token
27
29
  )
28
30
  }
29
31
 
30
- export function InfluenceText({ size = 13, basePath = '/products' }: TokenIconProps) {
32
+ export function InfluenceText({ size = 13, basePath = '/products', icon = true, className }: TokenIconProps) {
31
33
  return (
32
- <span style={{ fontFamily: 'var(--font-logo)', fontWeight: 700, whiteSpace: 'nowrap' }}>
33
- <InfluenceIcon size={size} basePath={basePath} />{' '}
34
+ <span style={{ fontFamily: 'var(--font-logo)', fontWeight: 700, whiteSpace: 'nowrap' }} className={className}>
35
+ {icon && <><InfluenceIcon size={size} basePath={basePath} />{' '}</>}
34
36
  <span className="brand-mey">Mey</span>
35
37
  <span style={{ color: 'var(--color-accent)' }}>Influence</span>
36
38
  </span>
37
39
  )
38
40
  }
39
41
 
40
- export function FateText({ size = 13, basePath = '/products' }: TokenIconProps) {
42
+ export function FateText({ size = 13, basePath = '/products', icon = true, className }: TokenIconProps) {
41
43
  return (
42
- <span style={{ fontFamily: 'var(--font-logo)', fontWeight: 700, whiteSpace: 'nowrap' }}>
43
- <FateIcon size={size} basePath={basePath} />{' '}
44
+ <span style={{ fontFamily: 'var(--font-logo)', fontWeight: 700, whiteSpace: 'nowrap' }} className={className}>
45
+ {icon && <><FateIcon size={size} basePath={basePath} />{' '}</>}
44
46
  <span className="brand-mey">Mey</span>
45
47
  <span style={{ color: 'var(--color-accent)' }}>Fate</span>
46
48
  </span>
47
49
  )
48
50
  }
51
+
52
+ /** App-ergonomic aliases. */
53
+ export const MeyInfluence = InfluenceText
54
+ export const MeyFate = FateText
package/src/brand.ts CHANGED
@@ -1,7 +1,10 @@
1
+ import type { Brand } from '@meyvoid/core/brand'
2
+
1
3
  export const BRAND = {
2
4
  name: 'MeyCult',
3
5
  first: 'Mey',
4
6
  second: 'Cult',
5
7
  tagline: 'Wager & Join',
6
8
  description: 'Where Foresight Becomes Fortune. Gamified prediction market on Base L2.',
7
- } as const
9
+ domain: 'meycult.com',
10
+ } as const satisfies Brand
@@ -5,15 +5,13 @@
5
5
  * SSR-safe (skeleton until mount, no hydration mismatch), screen-reader
6
6
  * friendly (role=timer stays silent; separate polite status per minute).
7
7
  */
8
- import { useEffect, useState, type CSSProperties } from 'react'
8
+ import { useEffect, useRef, useState, type CSSProperties } from 'react'
9
9
  import { angelRuns } from '@meyvoid/core/angel'
10
- import { cn } from '../cn'
10
+ import { diffParts, pad2, type CountdownParts } from '@meyvoid/core'
11
+ import { cn } from '@meyvoid/core/cn'
11
12
  import {
12
13
  UNVEIL_AT,
13
- diffParts,
14
14
  unveilDayLabel,
15
- pad2,
16
- type CountdownParts,
17
15
  } from '../unveil'
18
16
 
19
17
  export interface CultCountdownProps {
@@ -55,6 +53,52 @@ export function CultCountdown({
55
53
  // eslint-disable-next-line react-hooks/exhaustive-deps
56
54
  }, [day, hour, minute])
57
55
 
56
+ // tier holds: landing in an angel tier freezes that exact display, then
57
+ // the real countdown resumes — pair 2s, trio 10s, all-equal full house
58
+ // 60s. Tier-4 non-matching never holds (glow only). Cooldown stops
59
+ // slow cells (11:11 for a minute) refreezing every hold; a higher live
60
+ // tier mid-hold upgrades (trio never swallows a full-house second).
61
+ // NOTE: hooks live above the early returns — hook order must never change.
62
+ const HOLD_MS: Record<number, number> = { 2: 2000, 3: 10000, 4: 60000 }
63
+ const [frozen, setFrozen] = useState<string[] | null>(null)
64
+ const [frozenMask, setFrozenMask] = useState<boolean[] | null>(null)
65
+ const freezing = useRef(false)
66
+ const cooldownTier = useRef(0)
67
+ const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
68
+ useEffect(() => () => {
69
+ if (holdTimer.current) clearTimeout(holdTimer.current)
70
+ }, [])
71
+ const startHold = (tier: number, disp: string[], mask: boolean[]) => {
72
+ if (holdTimer.current) clearTimeout(holdTimer.current)
73
+ freezing.current = true
74
+ setFrozen(disp)
75
+ setFrozenMask(mask)
76
+ holdTimer.current = setTimeout(() => {
77
+ freezing.current = false
78
+ cooldownTier.current = tier
79
+ setFrozen(null)
80
+ setFrozenMask(null)
81
+ holdTimer.current = null
82
+ }, HOLD_MS[tier])
83
+ }
84
+ useEffect(() => {
85
+ if (!parts || parts.done) return
86
+ const vals = [parts.days, parts.hours, parts.minutes, parts.seconds]
87
+ const disp = vals.map(pad2)
88
+ const mask = angelRuns(vals)
89
+ const liveTier = mask.filter(Boolean).length
90
+ const liveEqual = disp.every((d) => d === disp[0])
91
+ const holdable = liveTier >= 2 && (liveTier <= 3 || liveEqual)
92
+ if (liveTier < cooldownTier.current) cooldownTier.current = 0
93
+ if (freezing.current) {
94
+ const heldTier = (frozenMask ?? mask).filter(Boolean).length
95
+ if (liveTier > heldTier && holdable) startHold(liveTier, disp, mask)
96
+ return
97
+ }
98
+ if (holdable && liveTier > cooldownTier.current) startHold(liveTier, disp, mask)
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [parts])
101
+
58
102
  if (!parts) {
59
103
  return (
60
104
  <div aria-hidden="true" className={cn('animate-pulse', className)}>
@@ -71,22 +115,49 @@ export function CultCountdown({
71
115
  )
72
116
  }
73
117
 
118
+ const shown: string[] = frozen ?? [
119
+ pad2(parts.days),
120
+ pad2(parts.hours),
121
+ pad2(parts.minutes),
122
+ pad2(parts.seconds),
123
+ ]
124
+ const angels: boolean[] = frozenMask ?? angelRuns([parts.days, parts.hours, parts.minutes, parts.seconds])
74
125
  const cells: Array<[string, string]> = [
75
- [pad2(parts.days), 'days'],
76
- [pad2(parts.hours), 'hrs'],
77
- [pad2(parts.minutes), 'min'],
78
- [pad2(parts.seconds), 'sec'],
126
+ [shown[0], 'days'],
127
+ [shown[1], 'hrs'],
128
+ [shown[2], 'min'],
129
+ [shown[3], 'sec'],
79
130
  ]
80
- // angel numbers glow yellow; everything else stays white
81
- const angels = angelRuns([parts.days, parts.hours, parts.minutes, parts.seconds])
131
+ // angel numbers glow yellow; everything else stays white.
132
+ // a linking ':' glows only when both sides are angelic AND match (22:22, never 22:11).
133
+ // progressive tiers: pair glows, trio blazes, full house burns.
134
+ const tier = angels.filter(Boolean).length
135
+ const GLOW_PAIR = '0 0 24px rgba(255,230,0,0.45)'
136
+ const GLOW_TRIO =
137
+ '0 0 24px rgba(255,230,0,0.6), 0 0 64px rgba(255,230,0,0.35), 0 0 120px rgba(255,230,0,0.2)'
138
+ const GLOW_FULL =
139
+ '0 0 32px rgba(255,230,0,0.8), 0 0 90px rgba(255,230,0,0.45), 0 0 160px rgba(255,230,0,0.25)'
140
+ // NOTE: pair/trio/full intensities stay literal — GLOW.brandCult is the ambient
141
+ // brand glow; these are brighter presentation tiers for the countdown moment.
142
+ const tierGlow = tier >= 4 ? GLOW_FULL : tier === 3 ? GLOW_TRIO : GLOW_PAIR
82
143
  const digitStyle = (angelic: boolean): CSSProperties =>
83
144
  angelic
84
145
  ? {
85
146
  fontFamily: 'var(--font-cult)',
86
147
  color: 'var(--color-accent)',
87
- textShadow: '0 0 24px rgba(255,230,0,0.45)',
148
+ textShadow: tierGlow,
88
149
  }
89
150
  : { fontFamily: 'var(--font-cult)', color: '#ffffff' }
151
+ const sepLinked = (i: number): boolean =>
152
+ angels[i] && angels[i + 1] && cells[i][0] === cells[i + 1][0]
153
+ const sepStyle = (linked: boolean): CSSProperties =>
154
+ linked
155
+ ? {
156
+ fontFamily: 'var(--font-cult)',
157
+ color: 'var(--color-accent)',
158
+ textShadow: tierGlow,
159
+ }
160
+ : { fontFamily: 'var(--font-cult)' }
90
161
 
91
162
  return (
92
163
  <div className={cn('text-center', className)}>
@@ -95,7 +166,11 @@ export function CultCountdown({
95
166
  aria-label={`Time until MeyCult Unveil, ${unveilDayLabel(resolved)}`}
96
167
  aria-live="off"
97
168
  aria-atomic="true"
98
- className="flex items-start justify-center gap-2 sm:gap-4"
169
+ className={
170
+ tier >= 3
171
+ ? 'countdown-bob flex items-start justify-center gap-2 sm:gap-4'
172
+ : 'flex items-start justify-center gap-2 sm:gap-4'
173
+ }
99
174
  >
100
175
  {cells.map(([v, l], i) => (
101
176
  <div key={l} className="flex items-start gap-2 sm:gap-4">
@@ -106,7 +181,7 @@ export function CultCountdown({
106
181
  <div aria-hidden="true" className="ui-label text-text-muted mt-1">{l}</div>
107
182
  </div>
108
183
  {i < cells.length - 1 && (
109
- <div aria-hidden="true" className="text-5xl sm:text-7xl text-accent/40" style={{ fontFamily: 'var(--font-cult)' }}>:</div>
184
+ <div aria-hidden="true" className="text-5xl sm:text-7xl text-accent/40" style={sepStyle(sepLinked(i))}>:</div>
110
185
  )}
111
186
  </div>
112
187
  ))}
@@ -0,0 +1,23 @@
1
+ import { User, Globe, Trophy, Bitcoin, Building2, Landmark } from 'lucide-react'
2
+ import type { Category, HeroType } from './types'
3
+
4
+ /** Quest taxonomy — single source (was duplicated in Temple/QuestDetail).
5
+ * Hexes stay literal: no theme tokens exist for categories (P0 lock). */
6
+ export const CATEGORY_COLORS: Record<Category, string> = {
7
+ POLITICS: '#3b82f6', TECH: '#8b5cf6', CRYPTO: '#f59e0b', SPORTS: '#22c55e', CULTURE: '#ec4899',
8
+ WORLD: '#dc2626', FINANCE: '#06b6d4', SCIENCE: '#6366f1', AI: '#d946ef', GAMING: '#84cc16', ENTERTAINMENT: '#f97316',
9
+ }
10
+
11
+ export const CATEGORY_LABELS: Record<Category, string> = {
12
+ POLITICS: 'Politics', TECH: 'Tech', CRYPTO: 'Crypto', SPORTS: 'Sports', CULTURE: 'Culture',
13
+ WORLD: 'World', FINANCE: 'Finance', SCIENCE: 'Science', AI: 'AI', GAMING: 'Gaming', ENTERTAINMENT: 'Entertainment',
14
+ }
15
+
16
+ export const HERO_TYPE_LABELS: Record<HeroType, { label: string; icon: typeof User }> = {
17
+ person: { label: 'People', icon: User },
18
+ nation: { label: 'Nations', icon: Globe },
19
+ sports_team: { label: 'Sports', icon: Trophy },
20
+ crypto: { label: 'Crypto', icon: Bitcoin },
21
+ corporation: { label: 'Corps', icon: Building2 },
22
+ organization: { label: 'Orgs', icon: Landmark },
23
+ }
@@ -0,0 +1,88 @@
1
+ import { COLORS } from '../tokens'
2
+ import type { CultId, VirtueName } from './types'
3
+
4
+ /** The four cults — single source. Merged from 5 page-local copies
5
+ * (Temple/QuestDetail/HeroProfile/Profile/Onboarding). Colors double as the
6
+ * canonical virtue palette (each cult is element-bound to one virtue). */
7
+ export interface CultMeta {
8
+ id: CultId
9
+ name: string
10
+ element: string
11
+ description: string
12
+ color: string
13
+ virtue: VirtueName
14
+ virtueLabel: string
15
+ bonus: string
16
+ }
17
+
18
+ export const CULT_META: Record<CultId, CultMeta> = {
19
+ driftless: {
20
+ id: 'driftless',
21
+ name: 'The Driftless',
22
+ element: 'Air',
23
+ description: 'Empty, yet inexhaustible. Masters of detachment who see through the fog.',
24
+ color: '#06b6d4',
25
+ virtue: 'clarity',
26
+ virtueLabel: 'Clarity',
27
+ bonus: '+10% when holding positions untouched for a cycle',
28
+ },
29
+ leviathan: {
30
+ id: 'leviathan',
31
+ name: 'Leviathan',
32
+ element: 'Water',
33
+ description: 'Grace flows downward. The lowest place is the highest.',
34
+ color: '#3b82f6',
35
+ virtue: 'humility',
36
+ virtueLabel: 'Humility',
37
+ bonus: 'Grace Pool: tithe winnings for mercy on ruin',
38
+ },
39
+ masonry: {
40
+ id: 'masonry',
41
+ name: 'The Masonry',
42
+ element: 'Earth',
43
+ description: 'Bear and forbear. The ground does not flee the storm.',
44
+ color: '#f59e0b',
45
+ virtue: 'endurance',
46
+ virtueLabel: 'Endurance',
47
+ bonus: 'Grounding Lock: outcome-independent returns',
48
+ },
49
+ recurrence: {
50
+ id: 'recurrence',
51
+ name: 'The Recurrence',
52
+ element: 'Fire',
53
+ description: 'Burn, and become. Loss is the only fuel that matters.',
54
+ color: '#ef4444',
55
+ virtue: 'overcoming',
56
+ virtueLabel: 'Overcoming',
57
+ bonus: 'Will-to-Power: losses multiply future payout',
58
+ },
59
+ }
60
+
61
+ export const CULT_ORDER: CultId[] = ['driftless', 'leviathan', 'masonry', 'recurrence']
62
+
63
+ export const VIRTUE_ORDER: VirtueName[] = ['clarity', 'humility', 'endurance', 'overcoming']
64
+
65
+ /** Flat id→color map (Temple/HeroProfile shape). */
66
+ export const CULT_COLORS: Record<CultId, string> = {
67
+ driftless: CULT_META.driftless.color,
68
+ leviathan: CULT_META.leviathan.color,
69
+ masonry: CULT_META.masonry.color,
70
+ recurrence: CULT_META.recurrence.color,
71
+ }
72
+
73
+ /** Fallback for unknown/uninitiated cult ids (was '#666'/'#6b7280' literals). */
74
+ export const FALLBACK_CULT: CultMeta = {
75
+ id: 'driftless',
76
+ name: 'Uninitiated',
77
+ element: '',
78
+ description: 'Join a cult to unlock your powers.',
79
+ color: COLORS.textMuted,
80
+ virtue: 'clarity',
81
+ virtueLabel: '',
82
+ bonus: '',
83
+ }
84
+
85
+ /** Safe lookup — never undefined. */
86
+ export function getCult(id: string): CultMeta {
87
+ return (CULT_META as Record<string, CultMeta>)[id] ?? FALLBACK_CULT
88
+ }
@@ -0,0 +1,62 @@
1
+ /** Shared quest-display formatters — single source (Temple impls win).
2
+ *
3
+ * Rarity language note — three separate tracks, do NOT merge:
4
+ * - Rarity (common/uncommon/rare/mythic): ITEM rarity (relics, loot).
5
+ * - rarityTier() (Common…Mythic + Refined/Shifting): QUEST density scale.
6
+ * - VirtueTierLabel (shifting/refined/uncommon/rare/mythic): VIRTUE tier names.
7
+ * Same words, different axes. The overlap is game lore, not a bug. */
8
+
9
+ export interface DensityTier {
10
+ label: string
11
+ color: string
12
+ }
13
+
14
+ /** Quest density → tier (thresholds 85/60/40/25/15). Colors stay literal (P0 lock). */
15
+ export function rarityTier(d: number): DensityTier {
16
+ if (d >= 85) return { label: 'Mythic', color: '#ef4444' }
17
+ if (d >= 60) return { label: 'Rare', color: '#f0c040' }
18
+ if (d >= 40) return { label: 'Uncommon', color: '#8b5cf6' }
19
+ if (d >= 25) return { label: 'Refined', color: '#3b82f6' }
20
+ if (d >= 15) return { label: 'Shifting', color: '#22c55e' }
21
+ return { label: 'Common', color: '#9ca3af' }
22
+ }
23
+
24
+ export function formatVolume(v: number): string {
25
+ if (v >= 1000000) return `${(v / 1000000).toFixed(1)}M`
26
+ if (v >= 1000) return `${(v / 1000).toFixed(0)}K`
27
+ return `${v}`
28
+ }
29
+
30
+ export interface Deadline {
31
+ text: string
32
+ urgent: boolean
33
+ }
34
+
35
+ /** Rich deadline (Temple impl): needs `now` for live ticking. */
36
+ export function formatDeadline(closesIn: string, now: number): Deadline {
37
+ if (!closesIn) return { text: '', urgent: false }
38
+ const d = new Date(closesIn)
39
+ if (isNaN(d.getTime())) return { text: '', urgent: false }
40
+ const diff = d.getTime() - now
41
+ if (diff < 0) return { text: 'Closed', urgent: true }
42
+ const days = Math.floor(diff / 86400000)
43
+ const hours = Math.floor((diff % 86400000) / 3600000)
44
+ const mins = Math.floor((diff % 3600000) / 60000)
45
+ const secs = Math.floor((diff % 60000) / 1000)
46
+ if (days >= 2) return { text: `Closes ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, urgent: false }
47
+ if (days >= 1) return { text: `Ends in ${days}d ${hours}h`, urgent: true }
48
+ if (hours >= 1) return { text: `Ends in ${hours}h ${mins}m`, urgent: true }
49
+ return { text: `Ends in ${mins}m ${secs}s`, urgent: true }
50
+ }
51
+
52
+ /** Short deadline string (QuestDetail impl): no `now` needed. */
53
+ export function formatDeadlineShort(closesIn: string): string {
54
+ const d = new Date(closesIn)
55
+ if (isNaN(d.getTime())) return closesIn
56
+ const diff = d.getTime() - Date.now()
57
+ if (diff < 0) return 'Closed'
58
+ const days = Math.floor(diff / 86400000)
59
+ if (days > 0) return `${days}d remaining`
60
+ const hours = Math.floor(diff / 3600000)
61
+ return `${hours}h remaining`
62
+ }
@@ -0,0 +1,27 @@
1
+ /** Shared Cult game types. App re-exports these from @/types (compat) —
2
+ * the game-specific remainder (Quest, Hero, Player, Wager, …) stays app-owned
3
+ * until a second brand needs it. */
4
+
5
+ export type VirtueName = 'clarity' | 'humility' | 'endurance' | 'overcoming'
6
+
7
+ export type Virtues = Record<VirtueName, number>
8
+
9
+ export type VirtueXP = Record<VirtueName, number>
10
+
11
+ export type VirtueTierLevel = 0 | 1 | 2 | 3
12
+
13
+ export type VirtueTierLabel = 'shifting' | 'refined' | 'uncommon' | 'rare' | 'mythic'
14
+
15
+ export type CultId = 'driftless' | 'leviathan' | 'masonry' | 'recurrence'
16
+
17
+ export type Category = 'POLITICS' | 'TECH' | 'CRYPTO' | 'SPORTS' | 'CULTURE' | 'WORLD' | 'FINANCE' | 'SCIENCE' | 'AI' | 'GAMING' | 'ENTERTAINMENT'
18
+
19
+ export type HeroType = 'person' | 'nation' | 'sports_team' | 'crypto' | 'corporation' | 'organization'
20
+
21
+ export type PredictionResult = 'PENDING' | 'WON' | 'LOST'
22
+
23
+ export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
24
+
25
+ export type BadgeTier = 'bronze' | 'silver' | 'gold' | 'platinum'
26
+
27
+ export type BadgeCategory = 'VIRTUE' | 'LOYALTY' | 'COLLECTION' | 'MARKET'