Initial commit

This commit is contained in:
2026-06-10 11:48:40 +02:00
commit da7fc72e30
17 changed files with 4193 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
# Bubble Feedback
Standalone Glitch University component that shows bubbles as self-amplifying feedback loops: output becomes input, and each completed loop pushes the chart higher.
## Development
- `npm install`
- `npm run dev`
- `npm run build`
The component follows the Glitch Component contract and registers itself as `window.GlitchComponents["bubble-feedback"]` 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": "bubble-feedback",
"displayName": "Bubble Feedback",
"version": "1.0.0",
"folderName": "glitch-bubble-simulator",
"packageName": "@glitch-components/bubble-feedback",
"entry": "dist/bubble-feedback.js",
"source": "src/index.tsx",
"tags": [
"glitch-component",
"bubble",
"feedback",
"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>Bubble Feedback</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
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@glitch-components/bubble-feedback",
"version": "1.0.0",
"description": "Bubble Feedback glitch component for Glitch University",
"type": "module",
"main": "./dist/bubble-feedback.js",
"module": "./dist/bubble-feedback.js",
"types": "./src/types.ts",
"exports": {
".": {
"import": "./dist/bubble-feedback.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",
"bubble",
"feedback",
"simulation",
"component"
],
"author": "Glitch.university",
"license": "MIT"
}
+432
View File
@@ -0,0 +1,432 @@
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 ScenarioKey = 'ai-stock' | 'string-theory'
interface LoopScenario {
key: ScenarioKey
title: string
shortLabel: string
subject: string
outputLabel: string
chartLabel: string
color: string
events: string[]
}
interface CustomScenario {
key?: string
title?: string
shortLabel?: string
subject?: string
outputLabel?: string
chartLabel?: string
color?: string
events?: string[]
}
interface ComponentParams {
scenario?: string
scenarios?: CustomScenario[]
animationSpeed?: number
feedbackStrength?: number
correctionPressure?: number
}
interface Palette {
accent: string
text: string
muted: string
grid: string
scenario: string
}
const DEFAULT_SCENARIOS: LoopScenario[] = [
{
key: 'ai-stock',
title: 'AI Stock Bubble',
shortLabel: 'AI STOCK',
subject: 'Market price becomes evidence',
outputLabel: 'price',
chartLabel: 'price expectation',
color: '#f0c45c',
events: [
'Stock price rises because people buy',
'Stronger financial position lets the company borrow more',
'Borrowing more results in larger AI investment',
'Larger AI investment increases AI race winning probability',
'AI race winning increases future earnings expectation'
]
},
{
key: 'string-theory',
title: 'String Theory Field Bubble',
shortLabel: 'STRING',
subject: 'Activity becomes evidence',
outputLabel: 'field activity',
chartLabel: 'field promise',
color: '#74d6ff',
events: [
'People choose String Theory for doctoral theses because of job opportunities',
'String Theory gets lots of new researchers',
'Researchers publish research and cite other string theorists',
'Activity and citations become proxy for how promising a research field is',
'Promising translates to successful grants',
'Grants become open positions'
]
}
]
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 createScenario = (scenario: CustomScenario, index: number): LoopScenario | null => {
if (!Array.isArray(scenario.events) || scenario.events.length < 2) return null
return {
key: (scenario.key ?? `custom-${index}`) as ScenarioKey,
title: scenario.title ?? `Custom Loop ${index + 1}`,
shortLabel: scenario.shortLabel ?? `LOOP ${index + 1}`,
subject: scenario.subject ?? 'Output becomes input',
outputLabel: scenario.outputLabel ?? 'output',
chartLabel: scenario.chartLabel ?? 'amplification',
color: scenario.color ?? '#8ee6a8',
events: scenario.events
}
}
const getScenarios = (params: ComponentParams) => {
const customScenarios = Array.isArray(params.scenarios)
? params.scenarios.map(createScenario).filter((scenario): scenario is LoopScenario => Boolean(scenario))
: []
return customScenarios.length > 0 ? customScenarios : DEFAULT_SCENARIOS
}
const getBubbleValue = (cycle: number, feedbackStrength: number, correctionPressure: number) => {
const drift = cycle * Math.max(0.02, feedbackStrength - 0.52) * 0.22
const compounding = Math.exp(cycle * Math.max(0, feedbackStrength - 0.86) * 0.11)
const correction = Math.sin(cycle * 1.38) * correctionPressure * 0.42
return clamp(0.1 + drift + compounding * 0.18 - correction, 0.08, 2.7)
}
function drawChart(
canvas: HTMLCanvasElement,
cycle: number,
feedbackStrength: number,
correctionPressure: 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.08
const padY = height * 0.14
const chartW = width - padX * 2
const chartH = height - padY * 2
ctx.lineWidth = 1
ctx.strokeStyle = palette.grid
for (let index = 0; index <= 4; index += 1) {
const y = padY + (index / 4) * chartH
ctx.beginPath()
ctx.moveTo(padX, y)
ctx.lineTo(width - padX, y)
ctx.stroke()
}
const samples = 80
const points = Array.from({ length: samples }, (_, index) => {
const sampleCycle = Math.max(0, cycle - 7 + (index / (samples - 1)) * 7)
const value = getBubbleValue(sampleCycle, feedbackStrength, correctionPressure)
return {
x: padX + (index / (samples - 1)) * chartW,
y: padY + chartH - (value / 2.7) * chartH
}
})
const gradient = ctx.createLinearGradient(0, padY, 0, height - padY)
gradient.addColorStop(0, palette.scenario)
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
ctx.beginPath()
points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y)
else ctx.lineTo(point.x, point.y)
})
ctx.lineTo(width - padX, height - padY)
ctx.lineTo(padX, height - padY)
ctx.closePath()
ctx.fillStyle = gradient
ctx.globalAlpha = 0.22
ctx.fill()
ctx.globalAlpha = 1
ctx.beginPath()
points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y)
else ctx.lineTo(point.x, point.y)
})
ctx.strokeStyle = palette.scenario
ctx.lineWidth = Math.max(2, width * 0.006)
ctx.stroke()
const latest = points[points.length - 1]
ctx.beginPath()
ctx.arc(latest.x, latest.y, Math.max(5, width * 0.014), 0, Math.PI * 2)
ctx.fillStyle = palette.accent
ctx.fill()
}
export default function Component({
config,
onComplete,
onProgress,
theme,
className,
host
}: GlitchComponentProps) {
const params = config.params as ComponentParams
const scenarios = useMemo(() => getScenarios(params), [params])
const [scenarioKey, setScenarioKey] = useState(() => String(params.scenario ?? scenarios[0].key))
const [running, setRunning] = useState(true)
const [cycle, setCycle] = useState(0)
const [completed, setCompleted] = useState(false)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTickRef = useRef(0)
const animationSpeed = clamp(numberParam(params.animationSpeed, 1), 0.25, 3)
const feedbackStrength = clamp(numberParam(params.feedbackStrength, 1.18), 0.55, 1.8)
const correctionPressure = clamp(numberParam(params.correctionPressure, 0.18), 0, 0.65)
const scenario = scenarios.find((item) => item.key === scenarioKey) ?? scenarios[0]
const phase = cycle % 1
const eventIndex = Math.floor(phase * scenario.events.length) % scenario.events.length
const nextEventIndex = (eventIndex + 1) % scenario.events.length
const bubbleValue = getBubbleValue(cycle, feedbackStrength, correctionPressure)
const pressure = Math.round(clamp((bubbleValue / 2.7) * 100, 0, 100))
const progress = Math.round(clamp((cycle / 12) * 100, 0, 100))
const palette = useMemo<Palette>(() => ({
accent: theme?.accent ?? '#22d3ee',
text: theme?.text ?? '#e8e8ec',
muted: theme?.textMuted ?? '#9999a8',
grid: 'rgba(232, 232, 236, 0.13)',
scenario: scenario.color
}), [scenario.color, theme?.accent, theme?.text, theme?.textMuted])
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,
'--bubble-color': scenario.color,
'--rotation': `${phase * 360}deg`
}) as CSSProperties, [phase, scenario.color, theme])
const redraw = useCallback(() => {
if (!canvasRef.current) return
drawChart(canvasRef.current, cycle, feedbackStrength, correctionPressure, palette)
}, [correctionPressure, cycle, feedbackStrength, palette])
useEffect(() => {
setScenarioKey(String(params.scenario ?? scenarios[0].key))
}, [params.scenario, scenarios])
useEffect(() => {
setCycle(0)
}, [scenarioKey])
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 = 34 / animationSpeed
if (running && timestamp - lastTickRef.current > interval) {
setCycle((current) => (current >= 12 ? 0 : current + 0.0125))
lastTickRef.current = timestamp
}
animationFrame = window.requestAnimationFrame(step)
}
animationFrame = window.requestAnimationFrame(step)
return () => window.cancelAnimationFrame(animationFrame)
}, [animationSpeed, running])
const playClick = useCallback((target: string) => {
host?.playSound?.('ui.button_click', { target, component: config.name })
}, [config.name, host])
const chooseScenario = useCallback((key: ScenarioKey) => {
playClick(key)
setRunning(true)
setScenarioKey(key)
}, [playClick])
const finish = useCallback(() => {
if (completed) return
setCompleted(true)
playClick('complete')
onComplete({
success: true,
score: 100,
data: {
scenario: scenario.key,
cycle,
pressure,
activeEvent: scenario.events[eventIndex],
nextEvent: scenario.events[nextEventIndex],
definition: 'A bubble is a self-amplifying feedback loop: output becomes input.',
configId: config.id,
completedAt: new Date().toISOString()
}
})
}, [completed, config.id, cycle, eventIndex, nextEventIndex, onComplete, playClick, pressure, scenario])
const rootClassName = className ? `${styles.wrapper} ${className}` : styles.wrapper
return (
<section className={rootClassName} style={themeStyle} aria-label="Bubble feedback simulator">
<div className={styles.frame}>
<header className={styles.header}>
<div>
<span className={styles.kicker}>Feedback loop</span>
<h2>A bubble is output used as input</h2>
</div>
<div className={styles.controls} aria-label="Simulation controls">
<button className={styles.iconButton} type="button" onClick={() => setRunning((value) => !value)}>
{running ? 'II' : '>'}
</button>
{scenarios.map((item) => (
<button
className={`${styles.modeButton} ${item.key === scenario.key ? styles.modeActive : ''}`}
key={item.key}
type="button"
onClick={() => chooseScenario(item.key)}
>
{item.shortLabel}
</button>
))}
</div>
</header>
<main className={styles.stageGrid}>
<section className={styles.loopPanel} aria-label={`${scenario.title} loop`}>
<div className={styles.loopHeader}>
<div>
<span className={styles.kicker}>{scenario.title}</span>
<strong>{scenario.subject}</strong>
</div>
<div className={styles.cycleReadout}>
<span>cycle</span>
<strong>{cycle.toFixed(1)}</strong>
</div>
</div>
<div className={styles.loop}>
<div className={styles.ring} aria-hidden="true" />
<div className={styles.pulse} aria-hidden="true" />
<div className={styles.core}>
<canvas ref={canvasRef} className={styles.canvas} aria-label={`${scenario.chartLabel} chart`} />
<div className={styles.coreOverlay}>
<span>{scenario.chartLabel}</span>
<strong>{pressure}%</strong>
</div>
</div>
{scenario.events.map((event, index) => {
const angle = (index / scenario.events.length) * Math.PI * 2 - Math.PI / 2
const x = 50 + Math.cos(angle) * 32
const y = 50 + Math.sin(angle) * 32
const labelX = Math.cos(angle) * 5.4
const labelY = Math.sin(angle) * 4.5
return (
<div
className={styles.loopStation}
key={event}
style={{
left: `${x}%`,
top: `${y}%`,
'--label-x': `${labelX}em`,
'--label-y': `${labelY}em`
} as CSSProperties}
>
<div className={`${styles.loopNode} ${index === eventIndex ? styles.nodeActive : ''}`}>
<span>{index + 1}</span>
</div>
<p className={`${styles.stationLabel} ${index === eventIndex ? styles.labelActive : ''}`}>
{event}
</p>
</div>
)
})}
</div>
</section>
<aside className={styles.eventPanel}>
<span className={styles.kicker}>Now feeding back</span>
<h3>{scenario.events[eventIndex]}</h3>
<div className={styles.nextEvent}>
<span>next</span>
<p>{scenario.events[nextEventIndex]}</p>
</div>
<div className={styles.definition}>
<span>definition</span>
<p>A bubble is a self-amplifying feedback loop: output becomes input.</p>
</div>
</aside>
</main>
<footer className={styles.footer}>
<input
aria-label="Loop cycle"
type="range"
min={0}
max={12}
step={0.05}
value={cycle}
onChange={(event) => {
playClick('cycle')
setRunning(false)
setCycle(Number(event.currentTarget.value))
}}
/>
<button className={styles.completeButton} type="button" onClick={finish} disabled={completed}>
{completed ? 'OK' : '✓'}
</button>
</footer>
</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;
}
+56
View File
@@ -0,0 +1,56 @@
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({
scenario: {
value: 'ai-stock',
options: {
'AI Stock Bubble': 'ai-stock',
'String Theory Field Bubble': 'string-theory'
}
},
animationSpeed: { value: 1, min: 0.25, max: 3, step: 0.25 },
feedbackStrength: { value: 1.18, min: 0.55, max: 1.8, step: 0.05 },
correctionPressure: { value: 0.18, min: 0, max: 0.65, step: 0.05 }
})
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>
)
+65
View File
@@ -0,0 +1,65 @@
import Component from './Component'
import type { GlitchComponentMetadata } from './types'
export default Component
export const metadata: GlitchComponentMetadata = {
name: 'bubble-feedback',
displayName: 'Bubble Feedback',
version: '1.0.0',
paramSchema: {
scenario: {
type: 'select',
label: 'Scenario',
default: 'ai-stock',
options: [
{ value: 'ai-stock', label: 'AI Stock Bubble' },
{ value: 'string-theory', label: 'String Theory Field Bubble' }
]
},
animationSpeed: {
type: 'range',
label: 'Animation Speed',
default: 1,
min: 0.25,
max: 3,
step: 0.25
},
feedbackStrength: {
type: 'range',
label: 'Feedback Strength',
default: 1.18,
min: 0.55,
max: 1.8,
step: 0.05
},
correctionPressure: {
type: 'range',
label: 'Correction Pressure',
default: 0.18,
min: 0,
max: 0.65,
step: 0.05
}
},
defaultParams: {
scenario: 'ai-stock',
animationSpeed: 1,
feedbackStrength: 1.18,
correctionPressure: 0.18
}
}
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 }
}
+451
View File
@@ -0,0 +1,451 @@
.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;
gap: 0.65em;
overflow: hidden;
padding: 1em;
color: var(--gc-text);
font-family: var(--gc-font-main);
font-size: clamp(10px, 1.08cqw, 18px);
line-height: 1.2;
background:
radial-gradient(circle at 16% 12%, color-mix(in srgb, var(--bubble-color) 22%, transparent), transparent 28%),
radial-gradient(circle at 82% 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,
.controls,
.loopHeader,
.footer,
.cycleReadout {
display: flex;
align-items: center;
}
.header,
.loopHeader,
.footer {
justify-content: space-between;
gap: 1em;
}
.header h2 {
margin: 0.1em 0 0;
font-family: var(--gc-font-display);
font-size: 1.42em;
line-height: 1;
letter-spacing: 0;
}
.kicker {
display: block;
color: color-mix(in srgb, var(--gc-accent) 78%, white);
font-family: var(--gc-font-mono);
font-size: 0.62em;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.controls {
gap: 0.45em;
}
.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.66em;
font-weight: 700;
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.5em;
min-height: 2.6em;
background: rgb(10 14 24 / 72%);
}
.modeButton {
min-height: 2.6em;
padding: 0 0.8em;
background: rgb(10 14 24 / 58%);
}
.modeActive {
color: #090b10;
background: linear-gradient(90deg, var(--bubble-color), color-mix(in srgb, var(--gc-accent) 42%, var(--bubble-color)));
border-color: rgb(255 255 255 / 56%);
}
.stageGrid {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.32fr) minmax(12em, 0.68fr);
gap: 0.65em;
}
.loopPanel,
.eventPanel {
min-width: 0;
min-height: 0;
border: 1px solid color-mix(in srgb, var(--gc-border) 76%, transparent);
background: rgb(5 9 18 / 56%);
}
.loopPanel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.3em;
padding: 0.75em;
}
.loopHeader strong {
display: block;
margin-top: 0.2em;
font-size: 0.86em;
font-weight: 650;
}
.cycleReadout {
align-items: flex-end;
flex-direction: column;
gap: 0.16em;
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.66em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cycleReadout strong {
color: var(--bubble-color);
font-family: var(--gc-font-display);
font-size: 2.1em;
line-height: 0.9;
letter-spacing: 0;
}
.loop {
position: relative;
width: min(100%, 34em);
aspect-ratio: 1;
align-self: center;
justify-self: center;
}
.ring {
position: absolute;
inset: 18%;
border: 0.28em solid color-mix(in srgb, var(--bubble-color) 44%, transparent);
border-radius: 50%;
box-shadow:
inset 0 0 1.7em rgb(255 255 255 / 5%),
0 0 1.6em color-mix(in srgb, var(--bubble-color) 28%, transparent);
}
.ring::after {
content: "";
position: absolute;
inset: -0.55em;
border-radius: 50%;
border: 1px dashed color-mix(in srgb, var(--gc-text) 24%, transparent);
}
.pulse {
position: absolute;
inset: 50%;
width: 1.05em;
height: 1.05em;
border-radius: 50%;
background: var(--gc-accent);
box-shadow:
0 0 0 0.4em color-mix(in srgb, var(--gc-accent) 14%, transparent),
0 0 1.4em color-mix(in srgb, var(--gc-accent) 80%, transparent);
transform: rotate(var(--rotation)) translateY(-10.7em) translate(-50%, -50%);
transform-origin: 0 0;
}
.core {
position: absolute;
inset: 34%;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--gc-border) 78%, transparent);
border-radius: 50%;
background: rgb(7 11 17 / 88%);
text-align: center;
}
.coreOverlay {
position: absolute;
inset: auto 12% 13%;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5em;
pointer-events: none;
}
.coreOverlay span {
max-width: 9em;
color: color-mix(in srgb, var(--gc-text-muted) 88%, white);
font-family: var(--gc-font-mono);
font-size: 0.58em;
font-weight: 700;
letter-spacing: 0.1em;
line-height: 1.1;
text-align: left;
text-transform: uppercase;
}
.coreOverlay strong {
color: var(--bubble-color);
font-family: var(--gc-font-display);
font-size: 1.28em;
line-height: 0.9;
}
.canvas {
display: block;
width: 100%;
height: 100%;
border: 0;
background:
linear-gradient(90deg, color-mix(in srgb, var(--bubble-color) 8%, transparent), transparent 40%),
rgb(7 11 17 / 78%);
}
.loopStation {
position: absolute;
z-index: 2;
width: 0;
height: 0;
transform: translate(-50%, -50%);
}
.loopNode {
position: absolute;
left: 0;
top: 0;
width: 2.4em;
height: 2.4em;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--gc-border) 80%, transparent);
border-radius: 50%;
background: rgb(9 13 22 / 94%);
color: var(--gc-text-muted);
font-family: var(--gc-font-mono);
font-size: 0.78em;
font-weight: 800;
transform: translate(-50%, -50%);
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
}
.nodeActive {
color: #071018;
background: var(--bubble-color);
border-color: rgb(255 255 255 / 72%);
box-shadow: 0 0 1.2em color-mix(in srgb, var(--bubble-color) 54%, transparent);
}
.stationLabel {
position: absolute;
left: 0;
top: 0;
width: 12.4em;
max-height: 4.6em;
overflow: hidden;
margin: 0;
padding: 0.48em 0.58em;
border: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
background: rgb(5 9 18 / 82%);
color: color-mix(in srgb, var(--gc-text-muted) 86%, white);
font-size: 0.62em;
line-height: 1.14;
transform: translate(calc(-50% + var(--label-x)), calc(-50% + var(--label-y)));
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
}
.labelActive {
border-color: color-mix(in srgb, var(--bubble-color) 70%, var(--gc-border));
background: color-mix(in srgb, var(--bubble-color) 18%, rgb(5 9 18));
color: var(--gc-text);
box-shadow: 0 0 1.1em color-mix(in srgb, var(--bubble-color) 26%, transparent);
}
.eventPanel {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1em;
padding: 0.9em;
}
.eventPanel h3 {
margin: 0;
color: var(--gc-text);
font-family: var(--gc-font-display);
font-size: 1.18em;
line-height: 1.04;
letter-spacing: 0;
}
.nextEvent {
padding-top: 0.75em;
border-top: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
}
.nextEvent span,
.definition span {
color: var(--bubble-color);
font-family: var(--gc-font-mono);
font-size: 0.62em;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nextEvent p,
.definition p {
margin: 0.35em 0 0;
color: color-mix(in srgb, var(--gc-text-muted) 86%, white);
font-size: 0.8em;
line-height: 1.22;
}
.definition {
padding-top: 0.75em;
border-top: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
}
.footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 0.62em 0.8em;
border: 1px solid color-mix(in srgb, var(--gc-border) 76%, transparent);
background: rgb(5 9 18 / 54%);
}
.footer input {
width: 100%;
accent-color: var(--bubble-color);
}
.completeButton {
min-width: 3em;
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, 1fr) auto;
font-size: clamp(10px, 3.15cqw, 16px);
}
.header {
align-items: flex-start;
flex-direction: column;
gap: 0.55em;
}
.header h2 {
font-size: 1.2em;
}
.controls {
width: 100%;
display: grid;
grid-template-columns: 3.4em repeat(2, minmax(0, 1fr));
}
.stageGrid {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
}
.loop {
width: min(100%, 29em);
}
.pulse {
transform: rotate(var(--rotation)) translateY(-9.2em) translate(-50%, -50%);
}
.eventPanel {
padding: 0.75em;
}
.eventPanel h3 {
font-size: 0.96em;
}
.nextEvent p {
display: none;
}
.stationLabel {
width: 10.8em;
max-height: 3.9em;
font-size: 0.54em;
}
}
+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: 'bubble-feedback',
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: 3002,
open: true
}
}))