Initial commit

This commit is contained in:
2026-06-10 11:48:40 +02:00
commit 40d67c1d6b
17 changed files with 4263 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
# Demographic Wave
Standalone Glitch University component that simulates how a large generation moves through age bands and changes which cohort's policy preferences dominate.
## Development
- `npm install`
- `npm run dev`
- `npm run build`
The component follows the Glitch Component contract and registers itself as `window.GlitchComponents["demographic-wave"]` when loaded from the built IIFE bundle.
+8
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
{
"componentId": "demographic-wave",
"displayName": "Demographic Wave",
"version": "1.0.0",
"folderName": "glitch_demographic_wave",
"packageName": "@glitch-components/demographic-wave",
"entry": "dist/demographic-wave.js",
"source": "src/index.tsx",
"tags": [
"glitch-component",
"demography",
"policy",
"simulation"
]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demographic Wave</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dev.tsx"></script>
</body>
</html>
+2919
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@glitch-components/demographic-wave",
"version": "1.0.0",
"description": "Demographic Wave glitch component for Glitch University",
"type": "module",
"main": "./dist/demographic-wave.js",
"module": "./dist/demographic-wave.js",
"types": "./src/types.ts",
"exports": {
".": {
"import": "./dist/demographic-wave.js",
"types": "./src/types.ts"
}
},
"files": [
"dist",
"src/types.ts",
"glitch.manifest.json"
],
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.1",
"leva": "^0.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vite-plugin-css-injected-by-js": "^3.5.2"
},
"keywords": [
"glitch",
"demography",
"simulation",
"component"
],
"author": "Glitch.university",
"license": "MIT"
}
+460
View File
@@ -0,0 +1,460 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import styles from './styles.module.css'
import type { GlitchComponentProps } from './types'
type GenerationKey = 'silent' | 'boomer' | 'post'
interface Stage {
min: number
title: string
copy: string
policy: string
}
interface ComponentParams {
startYear?: number
endYear?: number
animationSpeed?: number
waveScale?: number
startEqualized?: boolean
}
interface Palette {
silent: string
boomer: string
post: string
accent: string
grid: string
text: string
}
const COHORT_YEARS = Array.from({ length: 82 }, (_, index) => 1928 + index)
const BOOMER_CENTER_YEAR = 1957
const MIN_YEAR = 1946
const MAX_YEAR = 2038
const STAGES: Stage[] = [
{
min: 0,
title: 'SCHOOLS',
copy: 'A large child cohort pulls classrooms, teachers, and local budgets toward youth infrastructure.',
policy: 'Education and schools'
},
{
min: 18,
title: 'CAMPUS',
copy: 'The wave enters college and first jobs. Credentials, housing, and entry labor markets start to flex.',
policy: 'Universities and entry jobs'
},
{
min: 30,
title: 'FAMILY',
copy: 'The cohort becomes parents and buyers. Homes, childcare, transport, and suburbs absorb the demand.',
policy: 'Housing and family subsidies'
},
{
min: 50,
title: 'VOTES',
copy: 'High turnout meets cohort size. Tax, asset, and stability preferences become easier to pass.',
policy: 'Tax and asset rules'
},
{
min: 65,
title: 'RETIREMENT',
copy: 'The wave reaches old age. Health care, pensions, and age-linked spending gain political gravity.',
policy: 'Pensions and health care'
}
]
const GENERATIONS: { key: GenerationKey; label: string }[] = [
{ key: 'silent', label: 'Silent generation' },
{ key: 'boomer', label: 'Boomers' },
{ key: 'post', label: 'Later cohorts' }
]
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const numberParam = (value: unknown, fallback: number) => (typeof value === 'number' ? value : fallback)
const booleanParam = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
const cohortFamily = (birthYear: number): GenerationKey => {
if (birthYear >= 1946 && birthYear <= 1964) return 'boomer'
if (birthYear < 1946) return 'silent'
return 'post'
}
const turnout = (age: number) => {
if (age < 18 || age > 88) return 0
if (age < 30) return 0.42
if (age < 45) return 0.62
if (age < 65) return 0.82
return 0.9 - Math.max(0, age - 72) * 0.018
}
const stageForAge = (age: number) =>
STAGES.reduce((chosen, stage) => (age >= stage.min ? stage : chosen), STAGES[0])
const createCohortSize = (equalMode: boolean, waveScale: number) => (birthYear: number) => {
if (equalMode) return 1
const boomerPeak = Math.exp(-Math.pow((birthYear - BOOMER_CENTER_YEAR) / 7.5, 2)) * waveScale
const postDip = Math.exp(-Math.pow((birthYear - 1974) / 8, 2)) * -0.22
const echo = Math.exp(-Math.pow((birthYear - 1991) / 9, 2)) * 0.18
return Math.max(0.5, 0.78 + boomerPeak + postDip + echo)
}
function drawWave(
canvas: HTMLCanvasElement,
year: number,
equalMode: boolean,
waveScale: number,
palette: Palette
) {
const rect = canvas.getBoundingClientRect()
const scale = window.devicePixelRatio || 1
const width = rect.width
const height = rect.height
canvas.width = Math.floor(width * scale)
canvas.height = Math.floor(height * scale)
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(scale, 0, 0, scale, 0, 0)
ctx.clearRect(0, 0, width, height)
const padX = width * 0.07
const base = height * 0.72
const maxH = height * 0.68
const sizeFor = createCohortSize(equalMode, waveScale)
ctx.lineWidth = 1
ctx.strokeStyle = palette.grid
for (let index = 0; index <= 4; index += 1) {
const x = padX + (index / 4) * (width - padX * 2)
ctx.beginPath()
ctx.moveTo(x, height * 0.14)
ctx.lineTo(x, height * 0.88)
ctx.stroke()
}
for (const birthYear of COHORT_YEARS) {
const age = year - birthYear
if (age < 0 || age > 88) continue
const size = sizeFor(birthYear)
const x = padX + (age / 88) * (width - padX * 2)
const radius = Math.max(4, width * 0.006 + size * width * 0.009)
const y = base - size * maxH * 0.52 - Math.sin((birthYear + year) * 0.18) * height * 0.025
const family = cohortFamily(birthYear)
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fillStyle = palette[family]
ctx.globalAlpha = family === 'boomer' ? 0.94 : 0.68
ctx.fill()
ctx.globalAlpha = 1
}
const boomerAge = year - BOOMER_CENTER_YEAR
if (boomerAge < 0 || boomerAge > 88) return
const x = padX + (boomerAge / 88) * (width - padX * 2)
const y = base - sizeFor(BOOMER_CENTER_YEAR) * maxH * 0.52
ctx.beginPath()
ctx.moveTo(x, height * 0.16)
ctx.lineTo(x, height * 0.9)
ctx.strokeStyle = palette.boomer
ctx.globalAlpha = 0.42
ctx.lineWidth = 2
ctx.stroke()
ctx.globalAlpha = 1
ctx.beginPath()
ctx.arc(x, y, Math.max(16, width * 0.035), 0, Math.PI * 2)
ctx.strokeStyle = palette.accent
ctx.lineWidth = 3
ctx.stroke()
}
export default function Component({
config,
onComplete,
onProgress,
theme,
className,
host
}: GlitchComponentProps) {
const params = config.params as ComponentParams
const startYear = clamp(Math.round(numberParam(params.startYear, MIN_YEAR)), MIN_YEAR, MAX_YEAR)
const endYear = clamp(Math.round(numberParam(params.endYear, MAX_YEAR)), startYear + 1, MAX_YEAR)
const animationSpeed = clamp(numberParam(params.animationSpeed, 1), 0.25, 3)
const waveScale = clamp(numberParam(params.waveScale, 1.15), 0.2, 1.8)
const [year, setYear] = useState(startYear)
const [running, setRunning] = useState(true)
const [equalMode, setEqualMode] = useState(() => booleanParam(params.startEqualized, false))
const [completed, setCompleted] = useState(false)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTickRef = useRef(0)
const palette = useMemo<Palette>(() => ({
silent: '#71a6d2',
boomer: '#e7b84f',
post: '#7ee0a0',
accent: theme?.accent ?? '#22d3ee',
grid: 'rgba(232, 232, 236, 0.13)',
text: theme?.text ?? '#e8e8ec'
}), [theme?.accent, theme?.text])
const themeStyle = useMemo(() => ({
'--gc-primary': theme?.primary,
'--gc-accent': theme?.accent,
'--gc-bg': theme?.bg,
'--gc-bg-secondary': theme?.bgSecondary,
'--gc-text': theme?.text,
'--gc-text-muted': theme?.textMuted,
'--gc-border': theme?.border
}) as CSSProperties, [theme])
const sizeFor = useMemo(() => createCohortSize(equalMode, waveScale), [equalMode, waveScale])
const boomerAge = year - BOOMER_CENTER_YEAR
const activeStage = stageForAge(clamp(boomerAge, 0, 88))
const currentPressure = useMemo(() => {
const totals = Object.fromEntries(STAGES.map((stage) => [stage.policy, 0]))
for (const birthYear of COHORT_YEARS) {
const age = year - birthYear
if (age < 0 || age > 88) continue
const voteWeight = sizeFor(birthYear) * turnout(age)
totals[stageForAge(age).policy] += voteWeight
}
const total = Object.values(totals).reduce((sum, value) => sum + value, 0)
return total > 0 ? Math.round((totals[activeStage.policy] / total) * 100) : 0
}, [activeStage.policy, sizeFor, year])
const generationWins = useMemo(() => {
const wins = Object.fromEntries(GENERATIONS.map((generation) => [generation.key, 0])) as Record<GenerationKey, number>
for (let targetYear = startYear; targetYear <= year; targetYear += 1) {
const totals = Object.fromEntries(GENERATIONS.map((generation) => [generation.key, 0])) as Record<GenerationKey, number>
for (const birthYear of COHORT_YEARS) {
const age = targetYear - birthYear
if (age < 18 || age > 88) continue
totals[cohortFamily(birthYear)] += sizeFor(birthYear) * turnout(age)
}
const winner = GENERATIONS.reduce((chosen, generation) =>
totals[generation.key] > totals[chosen.key] ? generation : chosen
, GENERATIONS[0])
if (totals[winner.key] > 0) wins[winner.key] += 1
}
return wins
}, [sizeFor, startYear, year])
const maxWins = Math.max(...Object.values(generationWins), 1)
const progress = Math.round(((year - startYear) / Math.max(1, endYear - startYear)) * 100)
const redraw = useCallback(() => {
if (!canvasRef.current) return
drawWave(canvasRef.current, year, equalMode, waveScale, palette)
}, [equalMode, palette, waveScale, year])
useEffect(() => {
setYear((current) => clamp(current, startYear, endYear))
}, [endYear, startYear])
useEffect(() => {
setEqualMode(booleanParam(params.startEqualized, false))
}, [params.startEqualized])
useEffect(() => {
onProgress?.(progress)
}, [onProgress, progress])
useEffect(() => {
redraw()
const canvas = canvasRef.current
if (!canvas) return undefined
const resizeObserver = new ResizeObserver(redraw)
resizeObserver.observe(canvas)
return () => resizeObserver.disconnect()
}, [redraw])
useEffect(() => {
let animationFrame = 0
const step = (timestamp: number) => {
const interval = 260 / animationSpeed
if (running && timestamp - lastTickRef.current > interval) {
setYear((current) => (current >= endYear ? startYear : current + 1))
lastTickRef.current = timestamp
}
animationFrame = window.requestAnimationFrame(step)
}
animationFrame = window.requestAnimationFrame(step)
return () => window.cancelAnimationFrame(animationFrame)
}, [animationSpeed, endYear, running, startYear])
const playClick = useCallback((target: string) => {
host?.playSound?.('ui.button_click', { target, component: config.name })
}, [config.name, host])
const setMode = useCallback((nextEqualMode: boolean) => {
playClick(nextEqualMode ? 'equal-generations' : 'boomer-wave')
setEqualMode(nextEqualMode)
}, [playClick])
const handleSlider = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
playClick('timeline')
setRunning(false)
setYear(Number(event.currentTarget.value))
}, [playClick])
const finishProof = useCallback(() => {
if (completed) return
setCompleted(true)
playClick('complete')
onComplete({
success: true,
score: 100,
data: {
year,
mode: equalMode ? 'equal-generations' : 'boomer-wave',
generationWins,
currentPressure,
configId: config.id,
completedAt: new Date().toISOString()
}
})
}, [completed, config.id, currentPressure, equalMode, generationWins, onComplete, playClick, year])
const rootClassName = className ? `${styles.wrapper} ${className}` : styles.wrapper
return (
<section className={rootClassName} style={themeStyle} aria-label="Demographic wave simulator">
<div className={styles.frame}>
<header className={styles.header}>
<div className={styles.controls} aria-label="Simulation controls">
<button className={styles.iconButton} type="button" onClick={() => setRunning((value) => !value)}>
{running ? 'II' : '>'}
</button>
<button
className={`${styles.modeButton} ${!equalMode ? styles.modeActive : ''}`}
type="button"
onClick={() => setMode(false)}
>
W
</button>
<button
className={`${styles.modeButton} ${equalMode ? styles.modeActive : ''}`}
type="button"
onClick={() => setMode(true)}
>
=
</button>
</div>
</header>
<div className={styles.stageGrid}>
<div className={styles.wavePanel}>
<div className={styles.readoutRow}>
<div>
<strong className={styles.year}>{year}</strong>
</div>
<div className={styles.legend}>
{GENERATIONS.map((generation) => (
<span key={generation.key}>
<i className={styles[generation.key]} />
</span>
))}
</div>
</div>
<div className={styles.track}>
<div className={styles.ageMarks} aria-hidden="true">
<span>0</span>
<span>20</span>
<span>40</span>
<span>60</span>
<span>80</span>
</div>
<canvas ref={canvasRef} className={styles.canvas} />
<div className={styles.stageMarks} aria-hidden="true">
<span>Kids</span>
<span>Campus</span>
<span>Work</span>
<span>Assets</span>
<span>Care</span>
</div>
</div>
</div>
<aside className={styles.eventPanel}>
<h3>{activeStage.title}</h3>
<div className={styles.meter}>
<strong>{currentPressure}%</strong>
</div>
</aside>
</div>
<div className={styles.timeline}>
<input
aria-label="Simulation year"
type="range"
min={startYear}
max={endYear}
value={year}
onChange={handleSlider}
/>
<strong>{progress}%</strong>
</div>
<section className={styles.proofPanel} aria-label="Accepted policy preference winners">
<div className={styles.proofHeader}>
<div>
<h3>Policy wins</h3>
</div>
<div className={styles.proofActions}>
<span className={styles.modePill}>{equalMode ? '=' : 'W'}</span>
<button className={styles.completeButton} type="button" onClick={finishProof} disabled={completed}>
{completed ? 'OK' : '✓'}
</button>
</div>
</div>
<div className={styles.bars}>
{GENERATIONS.map((generation) => {
const percent = Math.round((generationWins[generation.key] / maxWins) * 100)
return (
<div className={styles.barRow} key={generation.key}>
<span>{generation.key === 'silent' ? 'S' : generation.key === 'boomer' ? 'B' : 'L'}</span>
<div className={styles.barTrack}>
<div
className={`${styles.barFill} ${styles[generation.key]}`}
style={{ width: `${percent}%` }}
/>
</div>
<strong>{percent}</strong>
</div>
)
})}
</div>
</section>
</div>
</section>
)
}
+49
View File
@@ -0,0 +1,49 @@
@import url("https://fonts.googleapis.com/css2?family=Iceland&family=JetBrains+Mono:wght@400;600&family=Russo+One&display=swap");
:root {
--color-bg: #0a0a0f;
--color-bg-secondary: #12121a;
--color-text: #e8e8ec;
--color-text-muted: #9999a8;
--color-primary: #6366f1;
--color-primary-hover: #818cf8;
--color-accent: #22d3ee;
--color-accent-secondary: #a855f7;
--color-border: #2a2a3a;
--font-main: "Iceland", -apple-system, BlinkMacSystemFont, sans-serif;
--font-display: "Russo One", -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: "JetBrains Mono", "Iceland", monospace;
--small-font: 1.875rem;
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 4rem;
--spacing-xl: 6rem;
--max-width: 1200px;
--border-radius: 12px;
--border-radius-sm: 8px;
--transition: 0.2s ease;
}
html,
body,
#root {
width: 100%;
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top, rgb(99 102 241 / 18%), transparent 36%),
linear-gradient(180deg, #080810, #0d1018);
color: var(--color-text);
font-family: var(--font-main);
}
button,
input,
select,
textarea {
font: inherit;
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Leva, useControls } from 'leva'
import Component from './Component'
import { metadata } from './index'
import './dev-theme.css'
function DevHarness() {
const params = useControls({
startYear: { value: 1946, min: 1946, max: 2028, step: 1 },
endYear: { value: 2038, min: 1964, max: 2038, step: 1 },
animationSpeed: { value: 1, min: 0.25, max: 3, step: 0.25 },
waveScale: { value: 1.15, min: 0.2, max: 1.8, step: 0.05 },
startEqualized: false
})
return (
<div style={{ minHeight: '100vh' }}>
<Leva hidden />
<Component
config={{
id: 'dev',
name: metadata.name,
version: metadata.version,
params: params as Record<string, unknown>
}}
theme={{
primary: '#6366f1',
accent: '#22d3ee',
bg: '#0a0a0f',
bgSecondary: '#12121a',
text: '#e8e8ec',
textMuted: '#9999a8',
border: '#2a2a3a'
}}
onProgress={(percent) => {
console.log('Progress:', percent)
}}
onComplete={(result) => {
console.log('Complete:', result)
}}
/>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<DevHarness />
</React.StrictMode>
)
+70
View File
@@ -0,0 +1,70 @@
import Component from './Component'
import type { GlitchComponentMetadata } from './types'
export default Component
export const metadata: GlitchComponentMetadata = {
name: 'demographic-wave',
displayName: 'Demographic Wave',
version: '1.0.0',
paramSchema: {
startYear: {
type: 'range',
label: 'Start Year',
default: 1946,
min: 1946,
max: 2028,
step: 1
},
endYear: {
type: 'range',
label: 'End Year',
default: 2038,
min: 1964,
max: 2038,
step: 1
},
animationSpeed: {
type: 'range',
label: 'Animation Speed',
default: 1,
min: 0.25,
max: 3,
step: 0.25
},
waveScale: {
type: 'range',
label: 'Boomer Wave Scale',
default: 1.15,
min: 0.2,
max: 1.8,
step: 0.05
},
startEqualized: {
type: 'boolean',
label: 'Start Equalized',
default: false
}
},
defaultParams: {
startYear: 1946,
endYear: 2038,
animationSpeed: 1,
waveScale: 1.15,
startEqualized: false
}
}
export type {
GlitchComponentProps,
GlitchComponentConfig,
GlitchComponentResult,
GlitchTheme,
GlitchHostBridge
} from './types'
if (typeof window !== 'undefined') {
type GCRegistry = Record<string, { default: typeof Component; metadata: GlitchComponentMetadata }>
;(window as unknown as { GlitchComponents: GCRegistry }).GlitchComponents ??= {}
;(window as unknown as { GlitchComponents: GCRegistry }).GlitchComponents[metadata.name] = { default: Component, metadata }
}
+494
View File
@@ -0,0 +1,494 @@
.wrapper {
--gc-bg: var(--color-bg, #0a0a0f);
--gc-bg-secondary: var(--color-bg-secondary, #12121a);
--gc-text: var(--color-text, #e8e8ec);
--gc-text-muted: var(--color-text-muted, #9999a8);
--gc-primary: var(--color-primary, #6366f1);
--gc-accent: var(--color-accent, #22d3ee);
--gc-border: var(--color-border, #2a2a3a);
--gc-font-main: var(--font-main, system-ui, sans-serif);
--gc-font-display: var(--font-display, var(--gc-font-main));
--gc-font-mono: var(--font-mono, monospace);
--gc-shell-padding: clamp(8px, 2vw, 18px);
width: 100%;
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--gc-shell-padding);
box-sizing: border-box;
}
.wrapper *,
.wrapper *::before,
.wrapper *::after {
box-sizing: inherit;
}
.frame {
position: relative;
width: min(100%, calc((100dvh - (var(--gc-shell-padding) * 2)) * 16 / 9));
max-height: calc(100dvh - (var(--gc-shell-padding) * 2));
aspect-ratio: 16 / 9;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: 0.5em;
overflow: hidden;
padding: 1em;
color: var(--gc-text);
font-family: var(--gc-font-main);
font-size: clamp(10px, 1.12cqw, 18px);
line-height: 1.25;
background:
radial-gradient(circle at 12% 10%, color-mix(in srgb, var(--gc-primary) 26%, transparent), transparent 34%),
radial-gradient(circle at 86% 16%, color-mix(in srgb, var(--gc-accent) 18%, transparent), transparent 30%),
linear-gradient(145deg, var(--gc-bg), var(--gc-bg-secondary));
border: 1px solid color-mix(in srgb, var(--gc-border) 74%, transparent);
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 4%),
0 18px 48px rgb(0 0 0 / 34%);
container-type: inline-size;
}
.header,
.stageGrid,
.readoutRow,
.proofHeader,
.timeline,
.controls,
.legend,
.meter {
display: flex;
align-items: center;
}
.header,
.readoutRow,
.proofHeader,
.meter {
justify-content: space-between;
gap: 1em;
}
.label {
display: block;
margin: 0 0 0.25em;
color: color-mix(in srgb, var(--gc-accent) 80%, white);
font-family: var(--gc-font-mono);
font-size: 0.62em;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.controls {
gap: 0.45em;
justify-content: stretch;
width: 100%;
}
.iconButton,
.modeButton,
.completeButton {
border: 1px solid color-mix(in srgb, var(--gc-border) 78%, transparent);
color: var(--gc-text);
font: inherit;
font-family: var(--gc-font-mono);
font-size: 0.68em;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
}
.iconButton:hover,
.modeButton:hover,
.completeButton:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--gc-accent) 62%, var(--gc-border));
}
.iconButton {
min-width: 3.7em;
min-height: 2.75em;
background: rgb(10 14 24 / 72%);
}
.modeButton {
min-height: 2.75em;
padding: 0 0.85em;
background: rgb(10 14 24 / 58%);
}
.modeActive {
color: #090b10;
background: linear-gradient(90deg, #e7b84f, color-mix(in srgb, var(--gc-accent) 58%, #e7b84f));
border-color: rgb(255 255 255 / 56%);
}
.stageGrid {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.36fr) minmax(12em, 0.64fr);
align-items: stretch;
gap: 0.65em;
}
.wavePanel,
.eventPanel,
.proofPanel,
.timeline {
min-width: 0;
border: 1px solid color-mix(in srgb, var(--gc-border) 76%, transparent);
background: rgb(5 9 18 / 54%);
}
.wavePanel {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.45em;
padding: 0.7em;
}
.year {
display: block;
font-family: var(--gc-font-display);
font-size: 1.85em;
line-height: 0.9;
}
.legend {
gap: 0.4em;
justify-content: end;
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.62em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.legend i {
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-right: 0;
border-radius: 999px;
}
.track {
position: relative;
min-height: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--gc-border) 70%, transparent);
background:
linear-gradient(90deg, rgb(113 166 210 / 6%), transparent 32%, rgb(231 184 79 / 8%) 62%, rgb(126 224 160 / 7%)),
rgb(7 11 17 / 78%);
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
.ageMarks,
.stageMarks {
position: absolute;
left: 7%;
right: 7%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
color: rgb(232 232 236 / 60%);
font-family: var(--gc-font-mono);
font-size: 0.58em;
font-weight: 600;
letter-spacing: 0.08em;
pointer-events: none;
text-transform: uppercase;
}
.ageMarks {
top: 0.8em;
}
.stageMarks {
bottom: 0.75em;
text-align: center;
}
.eventPanel {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: row;
gap: 1em;
padding: 0.9em;
}
.eventPanel h3,
.proofHeader h3 {
margin: 0;
color: var(--gc-text);
font-family: var(--gc-font-display);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.eventPanel h3 {
font-size: 1.1em;
}
.eventPanel p {
display: none;
margin: 0.65em 0 0;
color: color-mix(in srgb, var(--gc-text-muted) 86%, white);
font-size: 0.88em;
}
.meter {
margin-top: 0;
padding-top: 0;
border-top: 0;
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.7em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.meter strong {
color: #e7b84f;
font-family: var(--gc-font-display);
font-size: 2.2em;
letter-spacing: 0.02em;
}
.timeline {
display: grid;
grid-template-columns: minmax(0, 1fr) 3.5em;
gap: 0.8em;
padding: 0.62em 0.8em;
}
.timeline .label {
margin: 0;
}
.timeline input {
width: 100%;
accent-color: #e7b84f;
}
.timeline strong {
text-align: right;
font-family: var(--gc-font-mono);
font-size: 0.72em;
}
.proofPanel {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.8em;
padding: 0.8em;
}
.proofHeader {
grid-column: 1 / -1;
}
.proofHeader h3 {
display: none;
}
.modePill {
white-space: nowrap;
border: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
min-width: 3em;
padding: 0.45em 0.7em;
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.62em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.proofActions {
display: flex;
align-items: center;
gap: 0.45em;
}
.bars {
display: grid;
gap: 0.45em;
}
.barRow {
display: grid;
grid-template-columns: 1.3em minmax(0, 1fr) 2.2em;
align-items: center;
gap: 0.65em;
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.68em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.barTrack {
height: 1.35em;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--gc-border) 76%, transparent);
background: rgb(3 6 12 / 72%);
}
.barFill {
height: 100%;
transition: width 180ms ease;
}
.barRow strong {
color: var(--gc-text);
text-align: right;
}
.silent {
background: #71a6d2;
}
.boomer {
background: #e7b84f;
}
.post {
background: #7ee0a0;
}
.completeButton {
min-height: 2.35em;
padding: 0 0.8em;
background: color-mix(in srgb, var(--gc-primary) 28%, rgb(5 9 18));
}
.completeButton:disabled {
cursor: default;
opacity: 0.68;
transform: none;
}
@media (orientation: portrait) {
.frame {
width: min(100%, calc((100dvh - (var(--gc-shell-padding) * 2)) / 2));
aspect-ratio: 1 / 2;
grid-template-rows: auto minmax(0, 1.24fr) auto minmax(0, 0.56fr);
font-size: clamp(10px, 3.2cqw, 16px);
}
.title {
font-size: 1.2em;
}
.header {
align-items: flex-start;
flex-direction: column;
gap: 0.55em;
}
.controls {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.iconButton,
.modeButton {
min-height: 2.35em;
}
.stageGrid {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1.25fr) auto;
}
.wavePanel {
padding: 0.75em;
}
.readoutRow {
align-items: flex-start;
flex-direction: column;
gap: 0.45em;
}
.legend {
justify-content: start;
}
.eventPanel {
min-height: 0;
padding: 0.72em;
}
.eventPanel h3 {
font-size: 1em;
}
.eventPanel p {
margin-top: 0.42em;
font-size: 0.78em;
line-height: 1.22;
}
.meter {
padding-top: 0.48em;
}
.meter strong {
font-size: 1.7em;
}
.stageMarks {
display: none;
}
.proofPanel {
grid-template-columns: 1fr;
gap: 0.5em;
padding: 0.65em;
}
.proofHeader {
align-items: flex-start;
flex-direction: column;
gap: 0.45em;
}
.proofActions {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.proofHeader h3 {
font-size: 0.82em;
}
.barRow {
grid-template-columns: 1fr 2.2em;
gap: 0.32em;
font-size: 0.6em;
}
.barRow span {
grid-column: 1 / -1;
}
.barTrack {
height: 0.85em;
}
.completeButton {
min-height: 2.35em;
}
}
+59
View File
@@ -0,0 +1,59 @@
export interface GlitchComponentConfig {
id: string
name: string
version: string
params: Record<string, unknown>
}
export interface GlitchHostBridge {
playSound?: (id: string, payload?: Record<string, unknown>) => void
stopSound?: (id: string, payload?: Record<string, unknown>) => void
emit?: (type: string, payload?: unknown) => void
}
export interface GlitchTheme {
primary: string
accent: string
bg: string
bgSecondary: string
text: string
textMuted: string
border: string
}
export interface GlitchComponentResult {
success: boolean
score?: number
data?: unknown
error?: string
}
export interface GlitchComponentProps {
config: GlitchComponentConfig
onComplete: (result: GlitchComponentResult) => void
onProgress?: (percent: number) => void
theme?: GlitchTheme
className?: string
host?: GlitchHostBridge
}
export interface ParamSchema {
[key: string]: {
type: 'number' | 'string' | 'boolean' | 'color' | 'select' | 'range'
label?: string
description?: string
default: unknown
options?: { value: string | number; label: string }[]
min?: number
max?: number
step?: number
}
}
export interface GlitchComponentMetadata {
name: string
displayName: string
version: string
paramSchema: ParamSchema
defaultParams: Record<string, unknown>
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}
+32
View File
@@ -0,0 +1,32 @@
import { resolve } from 'path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
export default defineConfig(({ mode }) => ({
plugins: [react(), cssInjectedByJsPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.tsx'),
name: 'GlitchComponent',
fileName: 'demographic-wave',
formats: ['iife']
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
},
assetFileNames: 'assets/[name][extname]'
}
},
sourcemap: true,
minify: mode === 'production'
},
server: {
port: 3001,
open: true
}
}))