From 137d1a81910792aa74d12b198213b2b2a1b5c3dc Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Tue, 24 Mar 2026 11:30:14 +0100 Subject: [PATCH] Adding the first glitch gallery --- build.sh | 8 + copy.sh | 8 + dev.sh | 27 ++ onboarding2 | 1 + package.json | 12 + scripts/glitch-components.mjs | 288 +++++++++++++ scripts/new-glitch-component.mjs | 133 ++++++ setup-remotes.sh | 78 ++++ skills/CLAUDE.md | 405 ++++++++++++++++++ standards/glitch-component/contract.ts | 59 +++ standards/glitch-component/dev-theme.css | 47 ++ .../react-vite-glitch-component/README.md | 16 + .../glitch.manifest.json | 12 + .../react-vite-glitch-component/index.html | 12 + .../react-vite-glitch-component/package.json | 49 +++ .../src/Component.tsx | 56 +++ .../src/dev-theme.css | 42 ++ .../react-vite-glitch-component/src/dev.tsx | 74 ++++ .../react-vite-glitch-component/src/index.tsx | 37 ++ .../src/styles.module.css | 85 ++++ .../react-vite-glitch-component/src/types.ts | 59 +++ .../src/vite-env.d.ts | 1 + .../react-vite-glitch-component/tsconfig.json | 30 ++ .../vite.config.ts | 33 ++ 24 files changed, 1572 insertions(+) create mode 100755 build.sh create mode 100755 copy.sh create mode 100755 dev.sh create mode 160000 onboarding2 create mode 100644 package.json create mode 100644 scripts/glitch-components.mjs create mode 100644 scripts/new-glitch-component.mjs create mode 100755 setup-remotes.sh create mode 100644 skills/CLAUDE.md create mode 100644 standards/glitch-component/contract.ts create mode 100644 standards/glitch-component/dev-theme.css create mode 100644 templates/react-vite-glitch-component/README.md create mode 100644 templates/react-vite-glitch-component/glitch.manifest.json create mode 100644 templates/react-vite-glitch-component/index.html create mode 100644 templates/react-vite-glitch-component/package.json create mode 100644 templates/react-vite-glitch-component/src/Component.tsx create mode 100644 templates/react-vite-glitch-component/src/dev-theme.css create mode 100644 templates/react-vite-glitch-component/src/dev.tsx create mode 100644 templates/react-vite-glitch-component/src/index.tsx create mode 100644 templates/react-vite-glitch-component/src/styles.module.css create mode 100644 templates/react-vite-glitch-component/src/types.ts create mode 100644 templates/react-vite-glitch-component/src/vite-env.d.ts create mode 100644 templates/react-vite-glitch-component/tsconfig.json create mode 100644 templates/react-vite-glitch-component/vite.config.ts diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..032e6f0 --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +node ./scripts/glitch-components.mjs build "$@" diff --git a/copy.sh b/copy.sh new file mode 100755 index 0000000..d40cfa4 --- /dev/null +++ b/copy.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +node ./scripts/glitch-components.mjs release "$@" diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..7757b06 --- /dev/null +++ b/dev.sh @@ -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 " + 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 diff --git a/onboarding2 b/onboarding2 new file mode 160000 index 0000000..705e9e5 --- /dev/null +++ b/onboarding2 @@ -0,0 +1 @@ +Subproject commit 705e9e508bcdc509b4b49b00c2410755034f2ddf diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d9b0f4 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/glitch-components.mjs b/scripts/glitch-components.mjs new file mode 100644 index 0000000..db65bce --- /dev/null +++ b/scripts/glitch-components.mjs @@ -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}`) +} diff --git a/scripts/new-glitch-component.mjs b/scripts/new-glitch-component.mjs new file mode 100644 index 0000000..b9b0d3e --- /dev/null +++ b/scripts/new-glitch-component.mjs @@ -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 -- --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') diff --git a/setup-remotes.sh b/setup-remotes.sh new file mode 100755 index 0000000..8082730 --- /dev/null +++ b/setup-remotes.sh @@ -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 diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md new file mode 100644 index 0000000..b0e6284 --- /dev/null +++ b/skills/CLAUDE.md @@ -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; +} + +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; +} + +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 ( +
+

My GlitchComponent

+ +
+ ); +} +``` + +--- + +## 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); + + const params = useControls(levaSchema); + + return ( +
+ + { console.log('Complete:', r); alert(`Done! Score: ${r.score}`); }} + /> +
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render(); +``` + +--- + +## 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` diff --git a/standards/glitch-component/contract.ts b/standards/glitch-component/contract.ts new file mode 100644 index 0000000..f8f83b8 --- /dev/null +++ b/standards/glitch-component/contract.ts @@ -0,0 +1,59 @@ +export interface GlitchComponentConfig { + id: string + name: string + version: string + params: Record +} + +export interface GlitchHostBridge { + playSound?: (id: string, payload?: Record) => void + stopSound?: (id: string, payload?: Record) => 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 +} diff --git a/standards/glitch-component/dev-theme.css b/standards/glitch-component/dev-theme.css new file mode 100644 index 0000000..4ae9f79 --- /dev/null +++ b/standards/glitch-component/dev-theme.css @@ -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; +} diff --git a/templates/react-vite-glitch-component/README.md b/templates/react-vite-glitch-component/README.md new file mode 100644 index 0000000..cccf23e --- /dev/null +++ b/templates/react-vite-glitch-component/README.md @@ -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` diff --git a/templates/react-vite-glitch-component/glitch.manifest.json b/templates/react-vite-glitch-component/glitch.manifest.json new file mode 100644 index 0000000..747232a --- /dev/null +++ b/templates/react-vite-glitch-component/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" + ] +} diff --git a/templates/react-vite-glitch-component/index.html b/templates/react-vite-glitch-component/index.html new file mode 100644 index 0000000..d7dd39c --- /dev/null +++ b/templates/react-vite-glitch-component/index.html @@ -0,0 +1,12 @@ + + + + + + __COMPONENT_DISPLAY_NAME__ + + +
+ + + diff --git a/templates/react-vite-glitch-component/package.json b/templates/react-vite-glitch-component/package.json new file mode 100644 index 0000000..83b6d46 --- /dev/null +++ b/templates/react-vite-glitch-component/package.json @@ -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" +} diff --git a/templates/react-vite-glitch-component/src/Component.tsx b/templates/react-vite-glitch-component/src/Component.tsx new file mode 100644 index 0000000..248f083 --- /dev/null +++ b/templates/react-vite-glitch-component/src/Component.tsx @@ -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 ( +
+

__COMPONENT_DISPLAY_NAME__

+

+ Replace this scaffold UI with the actual glitch mechanic. Keep the contract and host token usage. +

+
+ Speed: {speed.toFixed(1)} + + +
+
+ ) +} diff --git a/templates/react-vite-glitch-component/src/dev-theme.css b/templates/react-vite-glitch-component/src/dev-theme.css new file mode 100644 index 0000000..9a111a5 --- /dev/null +++ b/templates/react-vite-glitch-component/src/dev-theme.css @@ -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); +} diff --git a/templates/react-vite-glitch-component/src/dev.tsx b/templates/react-vite-glitch-component/src/dev.tsx new file mode 100644 index 0000000..b59c09c --- /dev/null +++ b/templates/react-vite-glitch-component/src/dev.tsx @@ -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>((map, option) => { + map[option.label] = option.value + return map + }, {}) ?? {} + } + return acc + } + + acc[key] = schema.default + return acc + }, {} as Record) +} + +function DevHarness() { + const params = useControls(buildLevaSchema()) + + return ( +
+ + { + console.log('Progress:', percent) + }} + onComplete={(result) => { + console.log('Complete:', result) + }} + /> +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/templates/react-vite-glitch-component/src/index.tsx b/templates/react-vite-glitch-component/src/index.tsx new file mode 100644 index 0000000..ef9610f --- /dev/null +++ b/templates/react-vite-glitch-component/src/index.tsx @@ -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' diff --git a/templates/react-vite-glitch-component/src/styles.module.css b/templates/react-vite-glitch-component/src/styles.module.css new file mode 100644 index 0000000..f71f92d --- /dev/null +++ b/templates/react-vite-glitch-component/src/styles.module.css @@ -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); +} diff --git a/templates/react-vite-glitch-component/src/types.ts b/templates/react-vite-glitch-component/src/types.ts new file mode 100644 index 0000000..f8f83b8 --- /dev/null +++ b/templates/react-vite-glitch-component/src/types.ts @@ -0,0 +1,59 @@ +export interface GlitchComponentConfig { + id: string + name: string + version: string + params: Record +} + +export interface GlitchHostBridge { + playSound?: (id: string, payload?: Record) => void + stopSound?: (id: string, payload?: Record) => 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 +} diff --git a/templates/react-vite-glitch-component/src/vite-env.d.ts b/templates/react-vite-glitch-component/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/templates/react-vite-glitch-component/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/templates/react-vite-glitch-component/tsconfig.json b/templates/react-vite-glitch-component/tsconfig.json new file mode 100644 index 0000000..e65dca4 --- /dev/null +++ b/templates/react-vite-glitch-component/tsconfig.json @@ -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" + ] +} diff --git a/templates/react-vite-glitch-component/vite.config.ts b/templates/react-vite-glitch-component/vite.config.ts new file mode 100644 index 0000000..98090a8 --- /dev/null +++ b/templates/react-vite-glitch-component/vite.config.ts @@ -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 + } +}))