Adding the first glitch gallery

This commit is contained in:
2026-03-24 11:30:14 +01:00
commit 137d1a8191
24 changed files with 1572 additions and 0 deletions
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
node ./scripts/glitch-components.mjs build "$@"
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
node ./scripts/glitch-components.mjs release "$@"
Executable
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
if [ -z "$1" ]; then
echo "Please provide name of glitch component to run."
echo "Usage: ./dev.sh <component-name>"
exit 1
fi
COMPONENT="$1"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
COMPONENT_DIR="$SCRIPT_DIR/$COMPONENT"
if [ ! -d "$COMPONENT_DIR" ]; then
echo "Component '$COMPONENT' not found."
exit 1
fi
if [ ! -f "$COMPONENT_DIR/package.json" ]; then
echo "Component '$COMPONENT' has no package.json."
exit 1
fi
echo "Starting dev server for $COMPONENT..."
cd "$COMPONENT_DIR"
npm run dev
Submodule
+1
Submodule onboarding2 added at 705e9e508b
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@glitch-university/glitch-components-workspace",
"private": true,
"type": "module",
"scripts": {
"glitch:list": "node scripts/glitch-components.mjs list",
"glitch:validate": "node scripts/glitch-components.mjs validate",
"glitch:build": "node scripts/glitch-components.mjs build",
"glitch:release": "node scripts/glitch-components.mjs release",
"glitch:new": "node scripts/new-glitch-component.mjs"
}
}
+288
View File
@@ -0,0 +1,288 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const cwd = process.cwd()
function fail(message, exitCode = 1) {
console.error(message)
process.exit(exitCode)
}
function discoverComponents() {
return fs
.readdirSync(cwd, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('glitch_'))
.map((entry) => entry.name)
.filter((name) => fs.existsSync(path.join(cwd, name, 'package.json')))
.sort()
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function parseArgs(argv) {
const [command = 'list', ...rest] = argv
const options = {
command,
components: [],
hostDir: '',
stagingDir: path.join(cwd, '.glitch-release')
}
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index]
if (arg === '--host') {
options.hostDir = path.resolve(cwd, rest[index + 1] ?? '')
index += 1
continue
}
if (arg === '--staging') {
options.stagingDir = path.resolve(cwd, rest[index + 1] ?? '')
index += 1
continue
}
options.components.push(arg)
}
return options
}
function resolveComponents(requested) {
const discovered = discoverComponents()
if (requested.length === 0) return discovered
const missing = requested.filter((name) => !discovered.includes(name))
if (missing.length > 0) {
fail(`Unknown components: ${missing.join(', ')}`)
}
return requested
}
function loadComponent(componentDirName) {
const componentDir = path.join(cwd, componentDirName)
const packagePath = path.join(componentDir, 'package.json')
const manifestPath = path.join(componentDir, 'glitch.manifest.json')
const packageJson = readJson(packagePath)
const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : null
return {
componentDirName,
componentDir,
packagePath,
manifestPath,
packageJson,
manifest
}
}
function validateComponent(component) {
const requiredFiles = ['package.json', 'src/index.tsx', 'src/Component.tsx', 'src/types.ts', 'src/dev.tsx']
const errors = []
const warnings = []
const hasManifest = Boolean(component.manifest)
const hasStandardSignals = hasManifest
for (const relativePath of requiredFiles) {
if (!fs.existsSync(path.join(component.componentDir, relativePath))) {
if (hasStandardSignals) {
errors.push(`Missing required file: ${relativePath}`)
} else {
warnings.push(`Legacy structure is missing standard file: ${relativePath}`)
}
}
}
const hasViteConfig =
fs.existsSync(path.join(component.componentDir, 'vite.config.ts')) ||
fs.existsSync(path.join(component.componentDir, 'vite.config.mjs')) ||
fs.existsSync(path.join(component.componentDir, 'vite.config.js'))
if (!hasViteConfig) {
if (hasStandardSignals) {
errors.push('Missing required file: vite.config.ts')
} else {
warnings.push('Legacy structure is missing standard file: vite.config.ts')
}
}
if (!component.packageJson.scripts?.dev && hasStandardSignals) {
errors.push('Missing package script: dev')
}
if (!component.packageJson.scripts?.build && hasStandardSignals) {
errors.push('Missing package script: build')
}
const indexPath = path.join(component.componentDir, 'src', 'index.tsx')
if (fs.existsSync(indexPath)) {
const source = fs.readFileSync(indexPath, 'utf8')
if (!source.includes('export default') && hasStandardSignals) {
errors.push('src/index.tsx must export default')
}
if (!source.includes('export const metadata') && hasStandardSignals) {
errors.push('src/index.tsx must export metadata')
}
if (source.includes("import './dev.css'") || source.includes("import './dev-theme.css'")) {
warnings.push('Production entry imports a dev-only stylesheet')
}
}
const styleFiles = ['src/styles.css', 'src/styles.module.css'].filter((relativePath) =>
fs.existsSync(path.join(component.componentDir, relativePath))
)
for (const relativePath of styleFiles) {
const source = fs.readFileSync(path.join(component.componentDir, relativePath), 'utf8')
if (source.includes('@import url(')) {
warnings.push(`${relativePath} imports a remote stylesheet`)
}
}
if (!component.manifest) {
warnings.push('Missing glitch.manifest.json')
} else {
for (const key of ['componentId', 'displayName', 'version', 'folderName', 'packageName', 'entry']) {
if (!component.manifest[key]) {
errors.push(`glitch.manifest.json is missing "${key}"`)
}
}
}
if (!fs.existsSync(path.join(component.componentDir, 'README.md'))) {
warnings.push('Missing README.md')
}
return { errors, warnings }
}
function runBuild(component) {
const result = spawnSync('npm', ['run', 'build'], {
cwd: component.componentDir,
stdio: 'inherit'
})
if (result.status !== 0) {
fail(`Build failed for ${component.componentDirName}`, result.status ?? 1)
}
}
function ensureDirectory(directory) {
fs.mkdirSync(directory, { recursive: true })
}
function copyDirectoryContents(sourceDir, destinationDir) {
ensureDirectory(destinationDir)
for (const entry of fs.readdirSync(sourceDir)) {
fs.cpSync(path.join(sourceDir, entry), path.join(destinationDir, entry), {
force: true,
recursive: true
})
}
}
function releaseComponent(component, hostDir, stagingDir) {
const distDir = path.join(component.componentDir, 'dist')
if (!fs.existsSync(distDir)) {
fail(`Missing dist directory for ${component.componentDirName}. Run build first.`)
}
const manifest =
component.manifest ??
{
componentId: component.componentDirName.replace(/^glitch_/, '').replaceAll('_', '-'),
displayName: component.packageJson.description ?? component.packageJson.name,
version: component.packageJson.version,
folderName: component.componentDirName,
packageName: component.packageJson.name,
entry: `dist/${component.packageJson.main?.replace(/^\.\/dist\//, '') ?? 'index.js'}`,
source: 'src/index.tsx',
tags: ['legacy']
}
const releaseDir = path.join(hostDir, manifest.folderName)
copyDirectoryContents(distDir, releaseDir)
fs.writeFileSync(path.join(releaseDir, 'glitch.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
const stagingComponentDir = path.join(stagingDir, manifest.folderName)
copyDirectoryContents(distDir, stagingComponentDir)
fs.writeFileSync(
path.join(stagingComponentDir, 'glitch.manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
)
}
const options = parseArgs(process.argv.slice(2))
const componentNames = resolveComponents(options.components)
const components = componentNames.map(loadComponent)
switch (options.command) {
case 'list': {
for (const componentName of componentNames) {
console.log(componentName)
}
break
}
case 'validate': {
let errorCount = 0
let warningCount = 0
for (const component of components) {
const { errors, warnings } = validateComponent(component)
if (errors.length === 0 && warnings.length === 0) {
console.log(`OK ${component.componentDirName}`)
continue
}
console.log(component.componentDirName)
for (const error of errors) {
errorCount += 1
console.log(` ERROR ${error}`)
}
for (const warning of warnings) {
warningCount += 1
console.log(` WARN ${warning}`)
}
}
if (errorCount > 0) {
fail(`Validation failed with ${errorCount} error(s) and ${warningCount} warning(s).`)
}
console.log(`Validation passed with ${warningCount} warning(s).`)
break
}
case 'build': {
for (const component of components) {
console.log(`=== Building ${component.componentDirName} ===`)
runBuild(component)
}
break
}
case 'release': {
const hostDir =
options.hostDir ||
path.resolve(cwd, '..', 'gnommoweb', 'src', 'components', 'glitch')
ensureDirectory(hostDir)
ensureDirectory(options.stagingDir)
for (const component of components) {
console.log(`=== Releasing ${component.componentDirName} ===`)
runBuild(component)
releaseComponent(component, hostDir, options.stagingDir)
}
break
}
default:
fail(`Unknown command: ${options.command}`)
}
+133
View File
@@ -0,0 +1,133 @@
import fs from 'node:fs'
import path from 'node:path'
const cwd = process.cwd()
const templateDir = path.join(cwd, 'templates', 'react-vite-glitch-component')
function fail(message) {
console.error(message)
process.exit(1)
}
function toKebabCase(value) {
return value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase()
}
function toSnakeCase(value) {
return value.replace(/-/g, '_')
}
function toPascalCase(value) {
return value
.split('-')
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('')
}
function toTitleCase(value) {
return value
.split('-')
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ')
}
function parseArgs(argv) {
const args = [...argv]
const result = {
componentId: '',
title: '',
dir: ''
}
while (args.length > 0) {
const arg = args.shift()
if (!arg) continue
if (!result.componentId && !arg.startsWith('--')) {
result.componentId = arg
continue
}
if (arg === '--title') {
result.title = args.shift() ?? ''
continue
}
if (arg === '--dir') {
result.dir = args.shift() ?? ''
continue
}
fail(`Unknown argument: ${arg}`)
}
return result
}
function copyTemplate(sourceDir, destinationDir, replacements) {
fs.mkdirSync(destinationDir, { recursive: true })
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name)
const destinationPath = path.join(destinationDir, entry.name)
if (entry.isDirectory()) {
copyTemplate(sourcePath, destinationPath, replacements)
continue
}
const source = fs.readFileSync(sourcePath, 'utf8')
const rendered = Object.entries(replacements).reduce(
(content, [key, value]) => content.replaceAll(key, value),
source
)
fs.writeFileSync(destinationPath, rendered)
}
}
const options = parseArgs(process.argv.slice(2))
if (!options.componentId) {
fail('Usage: npm run glitch:new -- <component-id> --title "Display Name" [--dir /custom/path]')
}
if (!fs.existsSync(templateDir)) {
fail(`Template directory not found: ${templateDir}`)
}
const componentId = toKebabCase(options.componentId)
if (!componentId) {
fail('Component id resolved to an empty value.')
}
const folderName = `glitch_${toSnakeCase(componentId)}`
const displayName = options.title.trim() || toTitleCase(componentId)
const bundleName = componentId
const destinationDir = options.dir
? path.resolve(cwd, options.dir)
: path.join(cwd, folderName)
if (fs.existsSync(destinationDir)) {
fail(`Destination already exists: ${destinationDir}`)
}
copyTemplate(templateDir, destinationDir, {
'__COMPONENT_ID__': componentId,
'__COMPONENT_DISPLAY_NAME__': displayName,
'__LIB_GLOBAL_NAME__': toPascalCase(componentId),
'__FOLDER_NAME__': folderName,
'__BUNDLE_NAME__': bundleName
})
console.log(`Created ${folderName}`)
console.log(`Path: ${destinationDir}`)
console.log('Next steps:')
console.log(` cd ${folderName}`)
console.log(' npm install')
console.log(' npm run dev')
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# Sets up git remotes for all glitch-component folders and pushes to ramanujan.
#
# Usage:
# ./setup-remotes.sh # set remotes only
# ./setup-remotes.sh --push # set remotes and push all
#
# Reads GIT_USER / GIT_PASSWORD from ../.env if present, otherwise prompts.
set -e
REMOTE_HOST="ramanujan.glitch.university"
COMPONENTS_DIR="$(cd "$(dirname "$0")" && pwd)"
PUSH=false
[[ "$1" == "--push" ]] && PUSH=true
# ── Load credentials ──────────────────────────────────────────────────────────
ENV_FILE="$COMPONENTS_DIR/../gnommoweb/.env.prod"
if [ -f "$ENV_FILE" ]; then
GIT_USER=$(grep -E '^GIT_USER=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" | head -1)
GIT_PASSWORD=$(grep -E '^GIT_PASSWORD=' "$ENV_FILE" | cut -d= -f2 | tr -d '"'"'" | head -1)
fi
if [ -z "$GIT_USER" ] || [ -z "$GIT_PASSWORD" ]; then
read -rp "Git username: " GIT_USER
read -rsp "Git password: " GIT_PASSWORD
echo
fi
REMOTE_BASE="https://${GIT_USER}:${GIT_PASSWORD}@${REMOTE_HOST}"
# ── Process each component folder ─────────────────────────────────────────────
for dir in "$COMPONENTS_DIR"/*/; do
name=$(basename "$dir")
# Skip the skills folder and any non-component entries
[[ "$name" == "skills" ]] && continue
echo "── $name"
cd "$dir"
# Init if not already a git repo
if [ ! -d ".git" ]; then
git init -q
git add -A
git commit -q -m "Initial commit" 2>/dev/null || true
echo " initialised"
fi
# Add or update remote
if git remote get-url origin &>/dev/null; then
git remote set-url origin "${REMOTE_BASE}/${name}.git"
echo " remote updated"
else
git remote add origin "${REMOTE_BASE}/${name}.git"
echo " remote added"
fi
# Push
if $PUSH; then
if git push -u origin main -q 2>/dev/null || git push -u origin master:main -q 2>/dev/null; then
echo " pushed"
else
echo " push failed (repo may not exist on server yet)"
fi
fi
cd "$COMPONENTS_DIR"
done
echo ""
if $PUSH; then
echo "Done."
else
echo "Remotes configured. Run with --push to push all, or push individually."
fi
+405
View File
@@ -0,0 +1,405 @@
# Create GlitchComponent
Create a new GlitchComponent project that conforms to the standardized plugin architecture for Glitch University.
A glitchComponent is a way to create mini-games and functionality packages that can be easily included within Glitch University
Goal: Given this skill file, any capable developer should be able to create a GlitchComponent. The js file can be copied from dist into the public folder.
Then the component is registered, and from there - integrated within Glitch University.
Goal is to test Component mini-games outside the scope of the full platform. A glitchComponent is a way to create mini-games and functionality packages that can be easily included within Glitch University.
The tradeoffs are of course CSS style leakage (good and bad), sound system compatibility etc.
## Arguments
- `$ARGUMENTS` - Component name (e.g., "matrix-rain", "particle-system")
## Instructions
When creating a new GlitchComponent:
1. **If no directory specified**, create in a sibling directory: `../glitch-components/$ARGUMENTS/`
2. **Create the full project structure**:
```
$ARGUMENTS/
├── src/
│ ├── index.tsx # Main export with metadata
│ ├── Component.tsx # Component implementation
│ ├── styles.module.css # Scoped CSS styles
│ ├── types.ts # TypeScript interfaces
│ └── dev.tsx # Leva dev harness
├── vite.config.ts
├── package.json
├── tsconfig.json
└── index.html
```
3. After creation, explain how to:
- Run `cd backend & npm install && npm run dev` for local development
- Build with `npm run build`
- Deploy output to host app
---
## Architecture Principles
1. **Build as Library**: Use Vite Library Mode to output a self-contained bundle
2. **CSS Isolation**: Use CSS Modules to prevent style bleeding
3. **Inversion of Control**: Accept props for all configurable parameters
4. **Local Dev Support**: Use Leva controls during development, props in production
---
## Required TypeScript Interfaces
```typescript
// src/types.ts
export interface GlitchComponentConfig {
id: string;
name: string;
version: string;
params: Record<string, unknown>;
}
export interface GlitchComponentProps {
config: GlitchComponentConfig;
onComplete: (result: GlitchComponentResult) => void;
onProgress?: (percent: number) => void;
theme?: GlitchTheme;
className?: string;
}
export interface GlitchComponentResult {
success: boolean;
score?: number;
data?: unknown;
error?: string;
}
export interface GlitchTheme {
primary: string; // #6366f1
accent: string; // #22d3ee
bg: string; // #0a0a0f
bgSecondary: string; // #12121a
text: string; // #e8e8ec
textMuted: string; // #9999a8
border: string; // #2a2a3a
}
export interface GlitchComponentMetadata {
name: string;
displayName: string;
version: string;
paramSchema: ParamSchema;
defaultParams: Record<string, unknown>;
}
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;
};
}
```
---
## Main Export Structure
```typescript
// src/index.tsx
import Component from './Component';
import type { GlitchComponentMetadata } from './types';
export default Component;
export const metadata: GlitchComponentMetadata = {
name: 'my-component',
displayName: 'My Component',
version: '1.0.0',
paramSchema: {
speed: { type: 'range', label: 'Speed', default: 1.0, min: 0.1, max: 5.0, step: 0.1 },
primaryColor: { type: 'color', label: 'Color', default: '#6366f1' }
},
defaultParams: { speed: 1.0, primaryColor: '#6366f1' }
};
export type { GlitchComponentProps, GlitchComponentConfig, GlitchComponentResult } from './types';
```
---
## Component Template
```typescript
// src/Component.tsx
import { useCallback, useEffect, useState } from 'react';
import styles from './styles.module.css';
import type { GlitchComponentProps } from './types';
interface ComponentParams {
speed: number;
primaryColor: string;
}
export default function MyComponent({ config, onComplete, onProgress, theme, className }: GlitchComponentProps) {
const params = config.params as ComponentParams;
const { speed = 1.0, primaryColor } = params;
const handleComplete = useCallback((success: boolean, score?: number) => {
onComplete({ success, score, data: { completedAt: new Date().toISOString() } });
}, [onComplete]);
const cssVars = {
'--gc-primary': primaryColor || theme?.primary || '#6366f1',
'--gc-accent': theme?.accent || '#22d3ee',
'--gc-bg': theme?.bg || '#0a0a0f',
'--gc-text': theme?.text || '#e8e8ec',
} as React.CSSProperties;
return (
<div className={`${styles.container} ${className || ''}`} style={cssVars}>
<h2 className={styles.title}>My GlitchComponent</h2>
<button className={styles.button} onClick={() => handleComplete(true, 100)}>
Complete
</button>
</div>
);
}
```
---
## CSS Module Template
```css
/* src/styles.module.css */
.container {
background: var(--gc-bg, #0a0a0f);
color: var(--gc-text, #e8e8ec);
padding: 2rem;
border-radius: 12px;
box-sizing: border-box;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
.container *, .container *::before, .container *::after {
box-sizing: inherit;
}
.title {
color: var(--gc-primary, #6366f1);
margin: 0 0 1rem;
font-size: 1.5rem;
font-weight: 600;
}
.button {
background: var(--gc-primary, #6366f1);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s ease;
}
.button:hover { opacity: 0.9; }
.button:disabled { opacity: 0.5; cursor: not-allowed; }
```
---
## CSS variables
The following css variables are defined in the master project. Using these in the packaged css
should pick up the variables value in the deployed context.
For the testing context, please use the the following variables and definitions in a css file
````css
--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;
```
## Sounds
If you want to use sounds in the component, note that the app uses Howler,
and sound files are defined in the constant like this in the parent project,
```
const SOUND_DEFINITIONS = {
// ==========================================
// UI - General interactions
// ==========================================
'ui.button_click': { file: 'pop-402323.mp3', folder: 'ui', volume: 0.4 },
'ui.button_hover': { file: 'pop-402323.mp3', folder: 'ui', volume: 0.15 },
}
```
These sounds are called using code like this
```
playSound('ui.button_click');
```
This for info so that the sound system created for the componet can work with the containing framework.
`---
## Vite Configuration
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import { resolve } from 'path';
export default defineConfig(({ mode }) => ({
plugins: [react(), cssInjectedByJsPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.tsx'),
name: 'MyGlitchComponent',
fileName: 'my-glitch-component',
formats: ['es']
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: { react: 'React', 'react-dom': 'ReactDOM', 'react/jsx-runtime': 'jsxRuntime' },
assetFileNames: 'assets/[name][extname]'
}
},
sourcemap: true,
minify: mode === 'production'
},
server: { port: 3001, open: true }
}));
```
---
## Package.json
```json
{
"name": "my-glitch-component",
"version": "1.0.0",
"type": "module",
"main": "./dist/my-glitch-component.js",
"module": "./dist/my-glitch-component.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"leva": "^0.9.35",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-css-injected-by-js": "^3.3.0"
}
}
```
---
## Dev Harness with Leva
```typescript
// src/dev.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { useControls, Leva } from 'leva';
import Component from './Component';
import { metadata } from './index';
function DevHarness() {
const levaSchema = Object.entries(metadata.paramSchema).reduce((acc, [key, schema]) => {
if (schema.type === 'range' || schema.type === 'number') {
acc[key] = { value: schema.default, min: schema.min, max: schema.max, step: schema.step };
} else if (schema.type === 'select') {
acc[key] = { value: schema.default, options: schema.options?.reduce((o, opt) => ({ ...o, [opt.label]: opt.value }), {}) };
} else {
acc[key] = schema.default;
}
return acc;
}, {} as Record<string, unknown>);
const params = useControls(levaSchema);
return (
<div style={{ minHeight: '100vh', background: '#0a0a0f', padding: '2rem' }}>
<Leva collapsed={false} />
<Component
config={{ id: 'dev', name: metadata.name, version: metadata.version, params }}
theme={{ primary: '#6366f1', accent: '#22d3ee', bg: '#0a0a0f', bgSecondary: '#12121a', text: '#e8e8ec', textMuted: '#9999a8', border: '#2a2a3a' }}
onComplete={(r) => { console.log('Complete:', r); alert(`Done! Score: ${r.score}`); }}
/>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><DevHarness /></React.StrictMode>);
```
---
## Three.js / R3F Components
For 3D components, add to externals in vite.config.ts:
```typescript
external: ['react', 'react-dom', 'react/jsx-runtime', 'three', '@react-three/fiber', '@react-three/drei']
```
---
## Deployment
0. `nvm use 20` # to select node version 20
1. `npm run build`
2. Copy `dist/*.js` to host app's `src/components/glitch/`
3. Add database entry:
```sql
INSERT INTO glitch_components (name, display_name, version, file_path, param_schema, default_params)
VALUES ('my-component', 'My Component', '1.0.0', 'my-component.js', '{"speed":{"type":"range","default":1.0}}', '{"speed":1.0}');
```
4. Link to tech via `glitch_component_id`
+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>
}
+47
View File
@@ -0,0 +1,47 @@
: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;
}
@@ -0,0 +1,16 @@
# __COMPONENT_DISPLAY_NAME__
Standalone Glitch University component scaffolded from the workspace standard.
## Commands
- `npm install`
- `npm run dev`
- `npm run build`
- `npm run preview`
## Notes
- The packaged entrypoint is `src/index.tsx`
- Dev-only styling lives in `src/dev-theme.css`
- Host integration metadata lives in `glitch.manifest.json`
@@ -0,0 +1,12 @@
{
"componentId": "__COMPONENT_ID__",
"displayName": "__COMPONENT_DISPLAY_NAME__",
"version": "1.0.0",
"folderName": "__FOLDER_NAME__",
"packageName": "@glitch-components/__COMPONENT_ID__",
"entry": "dist/__BUNDLE_NAME__.js",
"source": "src/index.tsx",
"tags": [
"glitch-component"
]
}
@@ -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>__COMPONENT_DISPLAY_NAME__</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dev.tsx"></script>
</body>
</html>
@@ -0,0 +1,49 @@
{
"name": "@glitch-components/__COMPONENT_ID__",
"version": "1.0.0",
"description": "__COMPONENT_DISPLAY_NAME__ glitch component for Glitch University",
"type": "module",
"main": "./dist/__BUNDLE_NAME__.js",
"module": "./dist/__BUNDLE_NAME__.js",
"types": "./src/types.ts",
"exports": {
".": {
"import": "./dist/__BUNDLE_NAME__.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",
"mini-game",
"react",
"component"
],
"author": "Glitch.university",
"license": "MIT"
}
@@ -0,0 +1,56 @@
import type { CSSProperties } from 'react'
import styles from './styles.module.css'
import type { GlitchComponentProps } from './types'
interface ComponentParams {
speed?: number
primaryColor?: string
}
export default function GlitchComponent({
config,
onComplete,
onProgress,
theme,
className
}: GlitchComponentProps) {
const params = (config.params ?? {}) as ComponentParams
const speed = params.speed ?? 1
const componentTheme = {
'--component-bg': theme?.bg,
'--component-bg-secondary': theme?.bgSecondary,
'--component-text': theme?.text,
'--component-text-muted': theme?.textMuted,
'--component-primary': params.primaryColor ?? theme?.primary,
'--component-accent': theme?.accent,
'--component-border': theme?.border
} as CSSProperties
function handleComplete() {
onProgress?.(100)
onComplete({
success: true,
score: Math.round(100 * speed),
data: { completedAt: new Date().toISOString(), speed }
})
}
return (
<div className={[styles.root, className].filter(Boolean).join(' ')} style={componentTheme}>
<h2 className={styles.title}>__COMPONENT_DISPLAY_NAME__</h2>
<p className={styles.copy}>
Replace this scaffold UI with the actual glitch mechanic. Keep the contract and host token usage.
</p>
<div className={styles.row}>
<span className={styles.badge}>Speed: {speed.toFixed(1)}</span>
<button className={styles.button} onClick={() => onProgress?.(50)} type="button">
Progress 50%
</button>
<button className={styles.buttonPrimary} onClick={handleComplete} type="button">
Complete
</button>
</div>
</div>
)
}
@@ -0,0 +1,42 @@
@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);
}
@@ -0,0 +1,74 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Leva, useControls } from 'leva'
import Component from './Component'
import './dev-theme.css'
import { metadata } from './index'
function buildLevaSchema() {
return Object.entries(metadata.paramSchema).reduce((acc, [key, schema]) => {
if (schema.type === 'range' || schema.type === 'number') {
acc[key] = {
value: schema.default as number,
min: schema.min,
max: schema.max,
step: schema.step
}
return acc
}
if (schema.type === 'select') {
acc[key] = {
value: schema.default,
options:
schema.options?.reduce<Record<string, string | number>>((map, option) => {
map[option.label] = option.value
return map
}, {}) ?? {}
}
return acc
}
acc[key] = schema.default
return acc
}, {} as Record<string, unknown>)
}
function DevHarness() {
const params = useControls(buildLevaSchema())
return (
<div style={{ minHeight: '100vh', padding: '2rem' }}>
<Leva collapsed={false} />
<Component
config={{
id: 'dev',
name: metadata.name,
version: metadata.version,
params
}}
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>
)
@@ -0,0 +1,37 @@
import Component from './Component'
import type { GlitchComponentMetadata } from './types'
export default Component
export const metadata: GlitchComponentMetadata = {
name: '__COMPONENT_ID__',
displayName: '__COMPONENT_DISPLAY_NAME__',
version: '1.0.0',
paramSchema: {
speed: {
type: 'range',
label: 'Speed',
default: 1,
min: 0.1,
max: 5,
step: 0.1
},
primaryColor: {
type: 'color',
label: 'Primary Color',
default: '#6366f1'
}
},
defaultParams: {
speed: 1,
primaryColor: '#6366f1'
}
}
export type {
GlitchComponentProps,
GlitchComponentConfig,
GlitchComponentResult,
GlitchTheme,
GlitchHostBridge
} from './types'
@@ -0,0 +1,85 @@
.root {
--component-bg: var(--color-bg, #0a0a0f);
--component-bg-secondary: var(--color-bg-secondary, #12121a);
--component-text: var(--color-text, #e8e8ec);
--component-text-muted: var(--color-text-muted, #9999a8);
--component-primary: var(--color-primary, #6366f1);
--component-accent: var(--color-accent, #22d3ee);
--component-border: var(--color-border, #2a2a3a);
box-sizing: border-box;
width: min(100%, 900px);
margin: 0 auto;
padding: var(--spacing-md, 2rem);
border-radius: var(--border-radius, 12px);
border: 1px solid var(--component-border);
background:
radial-gradient(circle at 20% 10%, color-mix(in srgb, var(--component-primary) 16%, transparent), transparent 45%),
linear-gradient(180deg, var(--component-bg-secondary), var(--component-bg));
color: var(--component-text);
font-family: var(--font-main, system-ui, sans-serif);
line-height: 1.5;
}
.root,
.root *,
.root *::before,
.root *::after {
box-sizing: inherit;
}
.title {
margin: 0 0 var(--spacing-xs, 0.5rem);
color: var(--component-primary);
font-family: var(--font-display, var(--font-main, system-ui, sans-serif));
font-size: clamp(1.2rem, 2vw, 1.75rem);
}
.copy {
margin: 0 0 var(--spacing-sm, 1rem);
color: var(--component-text-muted);
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 0.5rem);
align-items: center;
}
.badge {
border: 1px solid var(--component-border);
border-radius: var(--border-radius-sm, 8px);
padding: 0.5rem 0.75rem;
font-family: var(--font-mono, monospace);
background: color-mix(in srgb, var(--component-primary) 8%, transparent);
}
.button,
.buttonPrimary {
border-radius: var(--border-radius-sm, 8px);
border: 1px solid var(--component-border);
padding: 0.65rem 0.9rem;
cursor: pointer;
font: inherit;
transition:
transform var(--transition, 0.2s ease),
opacity var(--transition, 0.2s ease),
border-color var(--transition, 0.2s ease);
}
.button {
background: var(--component-bg-secondary);
color: var(--component-text);
}
.buttonPrimary {
background: var(--component-primary);
border-color: color-mix(in srgb, var(--component-primary) 65%, white);
color: white;
}
.button:hover,
.buttonPrimary:hover {
opacity: 0.95;
transform: translateY(-1px);
}
@@ -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>
}
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -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"
]
}
@@ -0,0 +1,33 @@
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: '__LIB_GLOBAL_NAME__',
fileName: '__BUNDLE_NAME__',
formats: ['es']
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react/jsx-runtime': 'jsxRuntime'
},
assetFileNames: 'assets/[name][extname]'
}
},
sourcemap: true,
minify: mode === 'production'
},
server: {
port: 3001,
open: true
}
}))