Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Blood Sugar Delay Lab
|
||||||
|
|
||||||
|
Standalone Glitch University component that visualizes type 2 diabetes as a delayed feedback problem. The graph shows blood sugar, insulin level, and insulin response moving out of phase so learners can see how delay destabilizes a system that is trying to return to equilibrium.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm install`
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run preview`
|
||||||
|
|
||||||
|
From the workspace root, the standard wrappers also work:
|
||||||
|
|
||||||
|
- `npm run glitch:validate`
|
||||||
|
- `npm run glitch:build`
|
||||||
|
- `npm run glitch:release`
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- CDN release path: `/components/bloodsugar.js`
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# GlitchComponent Contract (Canonical Shape)
|
||||||
|
|
||||||
|
Use the local `lightlane` project as the canonical source when available:
|
||||||
|
|
||||||
|
- `../lightlane/src/types.ts`
|
||||||
|
- `../lightlane/src/index.tsx`
|
||||||
|
|
||||||
|
This reference captures the current shape observed in `lightlane` so the skill can scaffold quickly.
|
||||||
|
|
||||||
|
## Required Exports
|
||||||
|
|
||||||
|
`src/index.tsx` should export:
|
||||||
|
|
||||||
|
- `default` component
|
||||||
|
- `metadata` object (`GlitchComponentMetadata`)
|
||||||
|
- relevant types re-exported from `src/types.ts`
|
||||||
|
|
||||||
|
## Core Types (Current Lightlane-Compatible Shape)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface GlitchComponentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metadata Expectations
|
||||||
|
|
||||||
|
- `name`: kebab-case slug used by host registration
|
||||||
|
- `displayName`: human-friendly title
|
||||||
|
- `version`: semantic version string
|
||||||
|
- `paramSchema`: controls/settings schema for host and dev harness
|
||||||
|
- `defaultParams`: values that produce a working component without additional configuration
|
||||||
|
|
||||||
|
## Behavioral Expectations
|
||||||
|
|
||||||
|
- Call `onComplete(...)` when the experience completes or fails definitively.
|
||||||
|
- Call `onProgress(percent)` for multi-step flows when progress is meaningful.
|
||||||
|
- Use `config.params` as the single source of runtime config.
|
||||||
|
- Respect `theme` if provided, but keep fallbacks for standalone mode.
|
||||||
|
|
||||||
|
## Validation Tip
|
||||||
|
|
||||||
|
Before finalizing a scaffold, compare generated `src/types.ts` and `src/index.tsx` against the current `../lightlane/src/types.ts` and `../lightlane/src/index.tsx` to catch drift.
|
||||||
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"componentId": "bloodsugar",
|
||||||
|
"displayName": "Blood Sugar Delay Lab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"folderName": "glitch_bloodsugar",
|
||||||
|
"packageName": "@glitch-components/bloodsugar",
|
||||||
|
"entry": "dist/bloodsugar.js",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"glitch-component",
|
||||||
|
"biology",
|
||||||
|
"systems",
|
||||||
|
"feedback"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Create GlitchComponent
|
||||||
|
|
||||||
|
The canonical Glitch University component workflow now lives at the workspace root:
|
||||||
|
|
||||||
|
- `/Users/jenstandstad/Projects/glitch-components/GLITCH_COMPONENT_STANDARD.md`
|
||||||
|
- `/Users/jenstandstad/Projects/glitch-components/templates/react-vite-glitch-component/`
|
||||||
|
- `/Users/jenstandstad/Projects/glitch-components/scripts/new-glitch-component.mjs`
|
||||||
|
|
||||||
|
Use the root scaffold instead of copying this folder or reusing older component boilerplate.
|
||||||
|
|
||||||
|
For special features suchas 3D, Sound bridge etc consider the referenes in
|
||||||
|
|
||||||
|
/Users/jenstandstad/Projects/glitch-components/skills/references
|
||||||
|
|
||||||
+12
@@ -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>Blood Sugar Delay Lab</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/dev.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2919
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@glitch-components/bloodsugar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Blood Sugar Delay Lab glitch component for Glitch University",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/bloodsugar.js",
|
||||||
|
"module": "./dist/bloodsugar.js",
|
||||||
|
"types": "./src/types.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/bloodsugar.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,34 @@
|
|||||||
|
# Sound Bridge Notes
|
||||||
|
|
||||||
|
Glitch University uses a host-managed sound system (Howler-based) with string sound keys, for example:
|
||||||
|
|
||||||
|
- `playSound('ui.button_click')`
|
||||||
|
- `playSound('ui.button_hover')`
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Treat sound as an optional host integration:
|
||||||
|
|
||||||
|
- Use a small wrapper function in the component (`safePlaySound`) that checks whether a host sound function exists.
|
||||||
|
- No-op when unavailable (standalone dev should still run).
|
||||||
|
- Keep sound identifiers as strings/constants so they match host definitions.
|
||||||
|
|
||||||
|
## Example Pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PlaySound = (id: string) => void;
|
||||||
|
|
||||||
|
export function safePlaySound(playSound: PlaySound | undefined, id: string) {
|
||||||
|
try {
|
||||||
|
playSound?.(id);
|
||||||
|
} catch {
|
||||||
|
// Ignore host sound errors in standalone/local mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unknowns to Confirm Later
|
||||||
|
|
||||||
|
- How the host exposes `playSound` to glitch-components (prop, context, global import, event bus)
|
||||||
|
- Which sound keys are safe/standardized across experiences
|
||||||
|
- Whether completion/reward sounds are triggered by host or component
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
|
||||||
|
import styles from './styles.module.css'
|
||||||
|
import type { GlitchComponentProps, GlitchHostBridge } from './types'
|
||||||
|
|
||||||
|
interface BloodSugarParams {
|
||||||
|
responseDelay?: number
|
||||||
|
mealLoad?: number
|
||||||
|
pancreasStrength?: number
|
||||||
|
playbackSpeed?: number
|
||||||
|
primaryColor?: string
|
||||||
|
accentColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sample {
|
||||||
|
t: number
|
||||||
|
glucose: number
|
||||||
|
insulin: number
|
||||||
|
response: number
|
||||||
|
food: number
|
||||||
|
sensedGlucose: number
|
||||||
|
uptake: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonStageId =
|
||||||
|
| 'baseline'
|
||||||
|
| 'meal-start'
|
||||||
|
| 'glucose-peak'
|
||||||
|
| 'insulin-rise'
|
||||||
|
| 'glucose-fall'
|
||||||
|
| 'insulin-fall'
|
||||||
|
| 'return-baseline'
|
||||||
|
| 'loop-summary'
|
||||||
|
| 'lag-setup'
|
||||||
|
| 'lag-high'
|
||||||
|
| 'lag-insulin'
|
||||||
|
| 'late-action'
|
||||||
|
| 'crash'
|
||||||
|
| 'recovery'
|
||||||
|
| 'whiplash-summary'
|
||||||
|
| 'snack-setup'
|
||||||
|
| 'snack-bump'
|
||||||
|
| 'snack-insulin'
|
||||||
|
| 'calorie-cost'
|
||||||
|
| 'snack-summary'
|
||||||
|
|
||||||
|
interface LessonStage {
|
||||||
|
id: LessonStageId
|
||||||
|
eyebrow: string
|
||||||
|
title: string
|
||||||
|
copy: string
|
||||||
|
chartLabel: string
|
||||||
|
showFood: boolean
|
||||||
|
showInsulin: boolean
|
||||||
|
showResponse: boolean
|
||||||
|
showDelay: boolean
|
||||||
|
destabilized: boolean
|
||||||
|
showSnack?: boolean
|
||||||
|
snacking?: boolean
|
||||||
|
startMinute: number
|
||||||
|
stopMinute: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOUND_IDS = {
|
||||||
|
click: 'ui.button_click',
|
||||||
|
hover: 'ui.button_hover'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_COUNT = 520
|
||||||
|
const CYCLE_MINUTES = 700
|
||||||
|
const MEAL_START_MINUTE = 54
|
||||||
|
const MEAL_END_MINUTE = 84
|
||||||
|
const FOOD_ABSORPTION_PEAK_MINUTE = 102
|
||||||
|
const SNACK_START_MINUTE = 348
|
||||||
|
const SNACK_END_MINUTE = 368
|
||||||
|
const SNACK_ABSORPTION_PEAK_MINUTE = 388
|
||||||
|
const LOW_GLUCOSE = 72
|
||||||
|
const TARGET_GLUCOSE = 96
|
||||||
|
const HIGH_GLUCOSE = 145
|
||||||
|
|
||||||
|
const LESSON_STAGES: LessonStage[] = [
|
||||||
|
{
|
||||||
|
id: 'baseline',
|
||||||
|
eyebrow: 'Stage 1',
|
||||||
|
title: 'Blood sugar is a control loop.',
|
||||||
|
copy: 'No meal, no drama. The system stays near its set point.',
|
||||||
|
chartLabel: 'Blood sugar only',
|
||||||
|
showFood: false,
|
||||||
|
showInsulin: false,
|
||||||
|
showResponse: false,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 0,
|
||||||
|
stopMinute: 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meal-start',
|
||||||
|
eyebrow: 'Stage 2',
|
||||||
|
title: 'Food pushes glucose up.',
|
||||||
|
copy: 'The meal starts first. Blood sugar keeps rising while food is absorbed.',
|
||||||
|
chartLabel: 'Meal starts before the peak',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: false,
|
||||||
|
showResponse: false,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 0,
|
||||||
|
stopMinute: 92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'glucose-peak',
|
||||||
|
eyebrow: 'Stage 3',
|
||||||
|
title: 'Sugar rises first.',
|
||||||
|
copy: 'Insulin is not the cause of this peak. It is the answer to it.',
|
||||||
|
chartLabel: 'Glucose rises first',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: false,
|
||||||
|
showResponse: false,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 92,
|
||||||
|
stopMinute: 126
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insulin-rise',
|
||||||
|
eyebrow: 'Stage 4',
|
||||||
|
title: 'Insulin answers the rise.',
|
||||||
|
copy: 'The pancreas responds to glucose that was already high.',
|
||||||
|
chartLabel: 'Glucose causes insulin',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: false,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 126,
|
||||||
|
stopMinute: 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'glucose-fall',
|
||||||
|
eyebrow: 'Stage 5',
|
||||||
|
title: 'Then action lands in tissue.',
|
||||||
|
copy: 'Insulin action pulls glucose out of blood. The curve bends down.',
|
||||||
|
chartLabel: 'Insulin causes glucose uptake',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 156,
|
||||||
|
stopMinute: 206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insulin-fall',
|
||||||
|
eyebrow: 'Stage 6',
|
||||||
|
title: 'Lower sugar quiets insulin.',
|
||||||
|
copy: 'When glucose falls, the signal fades. The loop starts settling.',
|
||||||
|
chartLabel: 'Lower glucose lowers insulin',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 206,
|
||||||
|
stopMinute: 252
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'return-baseline',
|
||||||
|
eyebrow: 'Stage 7',
|
||||||
|
title: 'Good timing stabilizes.',
|
||||||
|
copy: 'The response arrives while it is still useful, so the loop returns to baseline.',
|
||||||
|
chartLabel: 'Back toward equilibrium',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 252,
|
||||||
|
stopMinute: 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loop-summary',
|
||||||
|
eyebrow: 'Stage 8',
|
||||||
|
title: 'The loop is simple.',
|
||||||
|
copy: 'Glucose calls. Insulin answers. Tissue acts. Timing decides whether this stabilizes.',
|
||||||
|
chartLabel: 'A stabilizing feedback loop',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: false,
|
||||||
|
destabilized: false,
|
||||||
|
startMinute: 0,
|
||||||
|
stopMinute: 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lag-setup',
|
||||||
|
eyebrow: 'Stage 9',
|
||||||
|
title: 'Now add only delay.',
|
||||||
|
copy: 'Same meal. Same loop. The answer just arrives late.',
|
||||||
|
chartLabel: 'Same loop, more lag',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 0,
|
||||||
|
stopMinute: 92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lag-high',
|
||||||
|
eyebrow: 'Stage 10',
|
||||||
|
title: 'This is the “resistance” moment.',
|
||||||
|
copy: 'It looks like glucose is not being handled. But in this model, the response is late.',
|
||||||
|
chartLabel: 'Lag lets glucose run high',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 92,
|
||||||
|
stopMinute: 132
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lag-insulin',
|
||||||
|
eyebrow: 'Stage 11',
|
||||||
|
title: 'The answer points backward.',
|
||||||
|
copy: 'Insulin is reacting to old glucose information. The system is chasing the past.',
|
||||||
|
chartLabel: 'The answer arrives late',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 132,
|
||||||
|
stopMinute: 186
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'late-action',
|
||||||
|
eyebrow: 'Stage 12',
|
||||||
|
title: 'Late correction finally hits.',
|
||||||
|
copy: 'Now uptake is strong, but the original glucose problem has already moved on.',
|
||||||
|
chartLabel: 'Correction after the moment passed',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 186,
|
||||||
|
stopMinute: 236
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crash',
|
||||||
|
eyebrow: 'Stage 13',
|
||||||
|
title: 'Late becomes too much.',
|
||||||
|
copy: 'A correction that would have helped earlier now overshoots. That is the crash.',
|
||||||
|
chartLabel: 'The crash',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 236,
|
||||||
|
stopMinute: 306
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recovery',
|
||||||
|
eyebrow: 'Stage 14',
|
||||||
|
title: 'The loop remembers.',
|
||||||
|
copy: 'Insulin action fades slowly. The body is still carrying the late response.',
|
||||||
|
chartLabel: 'Memory in the loop',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 306,
|
||||||
|
stopMinute: 440
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whiplash-summary',
|
||||||
|
eyebrow: 'Stage 15',
|
||||||
|
title: 'Timing made the whiplash.',
|
||||||
|
copy: 'High, then low, from the same loop. The problem is not just level. It is delay.',
|
||||||
|
chartLabel: 'Recovery without snacking',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
startMinute: 440,
|
||||||
|
stopMinute: 650
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snack-setup',
|
||||||
|
eyebrow: 'Stage 16',
|
||||||
|
title: 'The crash recruits behavior.',
|
||||||
|
copy: 'The low feels urgent. Snacking becomes the easiest way to push glucose back up.',
|
||||||
|
chartLabel: 'Snack added during the low',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
showSnack: true,
|
||||||
|
snacking: true,
|
||||||
|
startMinute: 306,
|
||||||
|
stopMinute: 368
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snack-bump',
|
||||||
|
eyebrow: 'Stage 17',
|
||||||
|
title: 'The snack works short term.',
|
||||||
|
copy: 'Glucose rises. The feeling improves. But the loop has not learned timing.',
|
||||||
|
chartLabel: 'Short-term relief',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
showSnack: true,
|
||||||
|
snacking: true,
|
||||||
|
startMinute: 368,
|
||||||
|
stopMinute: 414
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snack-insulin',
|
||||||
|
eyebrow: 'Stage 18',
|
||||||
|
title: 'Now the loop is triggered again.',
|
||||||
|
copy: 'The snack is another glucose input, so the delayed response starts chasing again.',
|
||||||
|
chartLabel: 'Another response is triggered',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
showSnack: true,
|
||||||
|
snacking: true,
|
||||||
|
startMinute: 414,
|
||||||
|
stopMinute: 464
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'calorie-cost',
|
||||||
|
eyebrow: 'Stage 19',
|
||||||
|
title: 'Relief has a calorie cost.',
|
||||||
|
copy: 'The snack solves the low by adding energy. Repeat that pattern and intake rises.',
|
||||||
|
chartLabel: 'Relief has a calorie cost',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
showSnack: true,
|
||||||
|
snacking: true,
|
||||||
|
startMinute: 464,
|
||||||
|
stopMinute: 560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snack-summary',
|
||||||
|
eyebrow: 'Stage 20',
|
||||||
|
title: 'Aha: the villain is delay.',
|
||||||
|
copy: 'What gets called resistance can be seen here as timing failure: late response, overshoot, crash, snack.',
|
||||||
|
chartLabel: 'Whiplash plus snacking',
|
||||||
|
showFood: true,
|
||||||
|
showInsulin: true,
|
||||||
|
showResponse: true,
|
||||||
|
showDelay: true,
|
||||||
|
destabilized: true,
|
||||||
|
showSnack: true,
|
||||||
|
snacking: true,
|
||||||
|
startMinute: 0,
|
||||||
|
stopMinute: 650
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function safePlaySound(host: GlitchHostBridge | undefined, id: string, payload?: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
host?.playSound?.(id, payload)
|
||||||
|
} catch {
|
||||||
|
// Host sound should never break standalone learning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown, fallback: number) {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MechanisticParams {
|
||||||
|
mealLoad: number
|
||||||
|
sensorDelay: number
|
||||||
|
actionDelay: number
|
||||||
|
betaGain: number
|
||||||
|
insulinClearance: number
|
||||||
|
insulinEffect: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FoodEvent {
|
||||||
|
center: number
|
||||||
|
load: number
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function mealAbsorption(minute: number, events: FoodEvent[]) {
|
||||||
|
return events.reduce((total, event) => {
|
||||||
|
const width = event.width ?? 24
|
||||||
|
const distance = minute - event.center
|
||||||
|
return total + event.load * Math.exp(-(distance * distance) / (2 * width * width))
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulate(params: MechanisticParams, events: FoodEvent[]) {
|
||||||
|
const samples: Sample[] = []
|
||||||
|
let glucose = TARGET_GLUCOSE
|
||||||
|
let insulin = 12
|
||||||
|
let insulinAction = 0
|
||||||
|
const dt = CYCLE_MINUTES / SAMPLE_COUNT
|
||||||
|
const delaySamples = Math.max(1, Math.round(params.sensorDelay / dt))
|
||||||
|
const glucoseHistory: number[] = Array(delaySamples + 2).fill(TARGET_GLUCOSE)
|
||||||
|
|
||||||
|
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
|
||||||
|
const minute = index * dt
|
||||||
|
const delayedGlucose = glucoseHistory[0] ?? TARGET_GLUCOSE
|
||||||
|
const secretionTarget = 12 + Math.max(0, delayedGlucose - TARGET_GLUCOSE) * params.betaGain
|
||||||
|
const food = mealAbsorption(minute, events)
|
||||||
|
const intake = food * 0.032
|
||||||
|
|
||||||
|
insulin += (secretionTarget - insulin) * (dt / params.insulinClearance)
|
||||||
|
insulin = clamp(insulin, 2, 140)
|
||||||
|
|
||||||
|
const actionTarget = Math.max(0, insulin - 12)
|
||||||
|
insulinAction += (actionTarget - insulinAction) * (dt / params.actionDelay)
|
||||||
|
insulinAction = clamp(insulinAction, 0, 130)
|
||||||
|
|
||||||
|
const insulinDrivenUptake = insulinAction * params.insulinEffect * (glucose / TARGET_GLUCOSE) * 0.018
|
||||||
|
const homeostaticReturn = (TARGET_GLUCOSE - glucose) * 0.012
|
||||||
|
glucose += intake + homeostaticReturn - insulinDrivenUptake
|
||||||
|
glucose = clamp(glucose, 50, 205)
|
||||||
|
|
||||||
|
samples.push({ t: minute, glucose, insulin, response: insulinAction, food, sensedGlucose: delayedGlucose, uptake: insulinDrivenUptake })
|
||||||
|
glucoseHistory.push(glucose)
|
||||||
|
glucoseHistory.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineSamples() {
|
||||||
|
return Array.from({ length: SAMPLE_COUNT }, (_, index) => {
|
||||||
|
const t = (index / (SAMPLE_COUNT - 1)) * CYCLE_MINUTES
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
glucose: TARGET_GLUCOSE,
|
||||||
|
insulin: 12,
|
||||||
|
response: 0,
|
||||||
|
food: 0,
|
||||||
|
sensedGlucose: TARGET_GLUCOSE,
|
||||||
|
uptake: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsFor(
|
||||||
|
samples: Sample[],
|
||||||
|
key: keyof Pick<Sample, 'glucose' | 'insulin' | 'response'>,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
endIndex: number
|
||||||
|
) {
|
||||||
|
return samples
|
||||||
|
.slice(0, endIndex + 1)
|
||||||
|
.map((sample, index) => {
|
||||||
|
const x = (index / (SAMPLE_COUNT - 1)) * 100
|
||||||
|
const y = 100 - ((sample[key] - min) / (max - min)) * 100
|
||||||
|
return `${x.toFixed(2)},${clamp(y, 4, 96).toFixed(2)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function xyFor(sample: Sample, index: number, min: number, max: number, key: keyof Pick<Sample, 'glucose' | 'insulin' | 'response'>) {
|
||||||
|
const x = (index / (SAMPLE_COUNT - 1)) * 100
|
||||||
|
const y = 100 - ((sample[key] - min) / (max - min)) * 100
|
||||||
|
return { x, y: clamp(y, 4, 96) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexFromMinute(minute: number) {
|
||||||
|
return clamp(Math.round((minute / CYCLE_MINUTES) * (SAMPLE_COUNT - 1)), 0, SAMPLE_COUNT - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMood(glucose: number) {
|
||||||
|
if (glucose >= HIGH_GLUCOSE) {
|
||||||
|
return {
|
||||||
|
emoji: '🥴',
|
||||||
|
label: 'High zone',
|
||||||
|
copy: 'Sugar is lingering above the healthy range while insulin is still catching up.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (glucose <= LOW_GLUCOSE) {
|
||||||
|
return {
|
||||||
|
emoji: '😠',
|
||||||
|
label: 'Low zone',
|
||||||
|
copy: 'The delayed response overcorrected. Energy dips, mood gets sharp.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji: '🙂',
|
||||||
|
label: 'Target zone',
|
||||||
|
copy: 'The system is near equilibrium, with fuel and response mostly in sync.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveStep(sample: Sample, stage: LessonStage) {
|
||||||
|
if (!stage.showFood) return 'baseline'
|
||||||
|
if (sample.food > 10) return 'food'
|
||||||
|
if (stage.showInsulin && sample.sensedGlucose > TARGET_GLUCOSE + 4 && sample.insulin < 24) return 'sense'
|
||||||
|
if (stage.showInsulin && sample.insulin > 24 && (!stage.showResponse || sample.response < 12)) return 'insulin'
|
||||||
|
if (stage.showResponse && sample.response > 12) return 'action'
|
||||||
|
return 'baseline'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlitchComponent({
|
||||||
|
config,
|
||||||
|
onComplete,
|
||||||
|
onProgress,
|
||||||
|
theme,
|
||||||
|
className,
|
||||||
|
host
|
||||||
|
}: GlitchComponentProps) {
|
||||||
|
const rawParams = (config.params ?? {}) as BloodSugarParams
|
||||||
|
const params = {
|
||||||
|
responseDelay: readNumber(rawParams.responseDelay, 34),
|
||||||
|
mealLoad: readNumber(rawParams.mealLoad, 44),
|
||||||
|
pancreasStrength: readNumber(rawParams.pancreasStrength, 1),
|
||||||
|
playbackSpeed: readNumber(rawParams.playbackSpeed, 1),
|
||||||
|
primaryColor: rawParams.primaryColor ?? theme?.primary ?? '#6366f1',
|
||||||
|
accentColor: rawParams.accentColor ?? theme?.accent ?? '#22d3ee'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cursor, setCursor] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [lessonIndex, setLessonIndex] = useState(0)
|
||||||
|
const stage = LESSON_STAGES[lessonIndex]
|
||||||
|
|
||||||
|
const samples = useMemo(() => {
|
||||||
|
if (stage.id === 'baseline') return baselineSamples()
|
||||||
|
|
||||||
|
const snackEvents: FoodEvent[] = stage.snacking
|
||||||
|
? [{ center: SNACK_ABSORPTION_PEAK_MINUTE, load: params.mealLoad * 0.7, width: 20 }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (stage.destabilized) {
|
||||||
|
return simulate(
|
||||||
|
{
|
||||||
|
mealLoad: params.mealLoad * 1.23,
|
||||||
|
sensorDelay: params.responseDelay + 4,
|
||||||
|
actionDelay: params.responseDelay * 1.5 + 15,
|
||||||
|
betaGain: params.pancreasStrength * 1.55,
|
||||||
|
insulinClearance: 32,
|
||||||
|
insulinEffect: 1.18
|
||||||
|
},
|
||||||
|
[{ center: FOOD_ABSORPTION_PEAK_MINUTE, load: params.mealLoad * 1.23, width: 24 }, ...snackEvents]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return simulate(
|
||||||
|
{
|
||||||
|
mealLoad: 30,
|
||||||
|
sensorDelay: 7,
|
||||||
|
actionDelay: 18,
|
||||||
|
betaGain: 0.82,
|
||||||
|
insulinClearance: 22,
|
||||||
|
insulinEffect: 1.08
|
||||||
|
},
|
||||||
|
[{ center: FOOD_ABSORPTION_PEAK_MINUTE, load: 30, width: 24 }]
|
||||||
|
)
|
||||||
|
}, [params.responseDelay, params.mealLoad, params.pancreasStrength, stage.destabilized, stage.id, stage.snacking])
|
||||||
|
|
||||||
|
const startIndex = indexFromMinute(stage.startMinute)
|
||||||
|
const stopIndex = indexFromMinute(stage.stopMinute)
|
||||||
|
const sample = samples[cursor] ?? samples[0]
|
||||||
|
const mood = getMood(sample.glucose)
|
||||||
|
const isHangry = sample.glucose <= LOW_GLUCOSE
|
||||||
|
const moodWord = isHangry ? 'Hangry' : 'Happy'
|
||||||
|
const glucoseNow = Math.round(sample.glucose)
|
||||||
|
const insulinNow = Math.round(sample.insulin)
|
||||||
|
const lagScore = clamp(Math.round((params.responseDelay / 80) * 100), 0, 100)
|
||||||
|
const instabilityScore = clamp(
|
||||||
|
Math.round((Math.max(...samples.map((entry) => entry.glucose)) - Math.min(...samples.map((entry) => entry.glucose))) * 0.9),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
const currentGlucose = xyFor(sample, cursor, 50, 200, 'glucose')
|
||||||
|
const currentInsulin = xyFor(sample, cursor, 0, 125, 'insulin')
|
||||||
|
const currentResponse = xyFor(sample, cursor, 0, 125, 'response')
|
||||||
|
const mealStartX = (MEAL_START_MINUTE / CYCLE_MINUTES) * 100
|
||||||
|
const mealEndX = (MEAL_END_MINUTE / CYCLE_MINUTES) * 100
|
||||||
|
const snackStartX = (SNACK_START_MINUTE / CYCLE_MINUTES) * 100
|
||||||
|
const snackEndX = (SNACK_END_MINUTE / CYCLE_MINUTES) * 100
|
||||||
|
const hasReachedFood = currentGlucose.x >= mealStartX
|
||||||
|
const hasReachedSnack = currentGlucose.x >= snackStartX
|
||||||
|
const delayOffset = (params.responseDelay / CYCLE_MINUTES) * 100
|
||||||
|
const delayedResponseX = clamp(currentGlucose.x - delayOffset, 0, 100)
|
||||||
|
const delayMidpointX = (delayedResponseX + currentGlucose.x) / 2
|
||||||
|
const activeStep = getActiveStep(sample, stage)
|
||||||
|
const sensedNow = Math.round(sample.sensedGlucose)
|
||||||
|
const uptakeNow = sample.uptake.toFixed(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) return
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setCursor((value) => (value >= stopIndex ? value : value + 1))
|
||||||
|
}, 42 / params.playbackSpeed)
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval)
|
||||||
|
}, [params.playbackSpeed, paused, stopIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stageSpan = Math.max(1, stopIndex - startIndex)
|
||||||
|
const stageProgress = (lessonIndex + clamp((cursor - startIndex) / stageSpan, 0, 1)) / LESSON_STAGES.length
|
||||||
|
onProgress?.(Math.min(95, Math.round(stageProgress * 95)))
|
||||||
|
}, [cursor, lessonIndex, onProgress, startIndex, stopIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCursor(startIndex)
|
||||||
|
setPaused(false)
|
||||||
|
}, [lessonIndex, startIndex])
|
||||||
|
|
||||||
|
const componentTheme = {
|
||||||
|
'--component-bg': theme?.bg,
|
||||||
|
'--component-bg-secondary': theme?.bgSecondary,
|
||||||
|
'--component-text': theme?.text,
|
||||||
|
'--component-text-muted': theme?.textMuted,
|
||||||
|
'--component-primary': params.primaryColor,
|
||||||
|
'--component-accent': params.accentColor,
|
||||||
|
'--component-border': theme?.border
|
||||||
|
} as CSSProperties
|
||||||
|
|
||||||
|
function togglePaused() {
|
||||||
|
safePlaySound(host, SOUND_IDS.click, { target: 'pause', paused: !paused })
|
||||||
|
setPaused((value) => !value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousStage() {
|
||||||
|
safePlaySound(host, SOUND_IDS.click, { target: 'previous-stage', lessonIndex })
|
||||||
|
setLessonIndex((value) => Math.max(0, value - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStage() {
|
||||||
|
safePlaySound(host, SOUND_IDS.click, { target: 'next-stage', lessonIndex })
|
||||||
|
if (lessonIndex >= LESSON_STAGES.length - 1) {
|
||||||
|
completeLesson()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLessonIndex((value) => Math.min(LESSON_STAGES.length - 1, value + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeLesson() {
|
||||||
|
safePlaySound(host, SOUND_IDS.click, { target: 'complete' })
|
||||||
|
onProgress?.(100)
|
||||||
|
onComplete({
|
||||||
|
success: true,
|
||||||
|
score: Math.max(1, 100 - Math.round((lagScore + instabilityScore) / 4)),
|
||||||
|
data: {
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
responseDelay: params.responseDelay,
|
||||||
|
instabilityScore,
|
||||||
|
lesson: 'Delay can turn a stabilizing feedback loop into an oscillating hysteresis loop.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.root, className].filter(Boolean).join(' ')} style={componentTheme}>
|
||||||
|
<div className={styles.board}>
|
||||||
|
<div className={styles.chartPanel}>
|
||||||
|
<div className={isHangry ? styles.hangryChartEmoji : styles.chartEmoji} aria-hidden="true">
|
||||||
|
{stage.showFood && !hasReachedFood ? '🍽️' : mood.emoji}
|
||||||
|
</div>
|
||||||
|
<div className={isHangry ? styles.hangryChartBadge : styles.happyChartBadge} aria-live="polite">
|
||||||
|
{moodWord}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<p className={styles.eyebrow}>{stage.eyebrow}</p>
|
||||||
|
<strong>{stage.chartLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.zoneLabels} aria-hidden="true">
|
||||||
|
<span>unhealthy high</span>
|
||||||
|
<span>target</span>
|
||||||
|
<span>cranky low</span>
|
||||||
|
</div>
|
||||||
|
<svg className={styles.chart} viewBox="0 0 100 100" role="img" aria-label="Animated graph of blood sugar, insulin level, and insulin response">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bloodsugarHighZone" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(248, 113, 113, 0.28)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(248, 113, 113, 0.06)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bloodsugarLowZone" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(34, 211, 238, 0.05)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(34, 211, 238, 0.26)" />
|
||||||
|
</linearGradient>
|
||||||
|
<marker id="bloodsugarDelayArrow" markerHeight="4" markerWidth="5" orient="auto" refX="4.4" refY="2">
|
||||||
|
<path d="M0,0 L5,2 L0,4 Z" fill="#fbbf24" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="100" height="37" fill="url(#bloodsugarHighZone)" />
|
||||||
|
<rect x="0" y="83" width="100" height="17" fill="url(#bloodsugarLowZone)" />
|
||||||
|
{[20, 40, 60, 80].map((line) => (
|
||||||
|
<line className={styles.gridLine} key={line} x1="0" x2="100" y1={line} y2={line} />
|
||||||
|
))}
|
||||||
|
<line className={styles.targetLine} x1="0" x2="100" y1="69.33" y2="69.33" />
|
||||||
|
{stage.showFood ? (
|
||||||
|
<>
|
||||||
|
<rect className={styles.mealWindow} x={mealStartX} y="5" width={mealEndX - mealStartX} height="91" />
|
||||||
|
<line className={styles.foodLine} x1={mealStartX} x2={mealStartX} y1="5" y2="96" />
|
||||||
|
<text className={styles.foodMarker} x={mealStartX} y="12" textAnchor="middle">
|
||||||
|
🍽️
|
||||||
|
</text>
|
||||||
|
{stage.showSnack ? (
|
||||||
|
<>
|
||||||
|
<rect className={styles.snackWindow} x={snackStartX} y="5" width={snackEndX - snackStartX} height="91" />
|
||||||
|
<line className={styles.snackLine} x1={snackStartX} x2={snackStartX} y1="5" y2="96" />
|
||||||
|
<text className={styles.foodMarker} x={snackStartX} y="12" textAnchor="middle">
|
||||||
|
🍬
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<polyline className={styles.glucoseLine} points={pointsFor(samples, 'glucose', 50, 200, cursor)} />
|
||||||
|
{stage.showInsulin ? <polyline className={styles.insulinLine} points={pointsFor(samples, 'insulin', 0, 125, cursor)} /> : null}
|
||||||
|
{stage.showResponse ? <polyline className={styles.responseLine} points={pointsFor(samples, 'response', 0, 125, cursor)} /> : null}
|
||||||
|
<line className={styles.cursorLine} x1={currentGlucose.x} x2={currentGlucose.x} y1="0" y2="100" />
|
||||||
|
{stage.showDelay ? (
|
||||||
|
<g className={styles.delayCallout}>
|
||||||
|
<line
|
||||||
|
className={styles.delayLine}
|
||||||
|
markerEnd="url(#bloodsugarDelayArrow)"
|
||||||
|
x1={delayedResponseX}
|
||||||
|
x2={currentGlucose.x}
|
||||||
|
y1="18"
|
||||||
|
y2="18"
|
||||||
|
/>
|
||||||
|
<text className={styles.delayText} x={delayMidpointX} y="16" textAnchor="middle">
|
||||||
|
late response
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
) : null}
|
||||||
|
<circle className={styles.glucoseDot} cx={currentGlucose.x} cy={currentGlucose.y} r="2.2" />
|
||||||
|
{stage.showInsulin ? <circle className={styles.insulinDot} cx={currentInsulin.x} cy={currentInsulin.y} r="1.9" /> : null}
|
||||||
|
{stage.showResponse ? <circle className={styles.responseDot} cx={currentResponse.x} cy={currentResponse.y} r="1.6" /> : null}
|
||||||
|
</svg>
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<span><i className={styles.glucoseKey} /> Blood sugar</span>
|
||||||
|
{stage.showFood ? <span><i className={styles.foodKey} /> Food event</span> : null}
|
||||||
|
{stage.showSnack ? <span><i className={styles.snackKey} /> Snack</span> : null}
|
||||||
|
{stage.showInsulin ? <span><i className={styles.insulinKey} /> Insulin level</span> : null}
|
||||||
|
{stage.showResponse ? <span><i className={styles.responseKey} /> Insulin action in tissue</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.controlPanel}>
|
||||||
|
<div className={styles.controlHeader}>
|
||||||
|
<p className={styles.eyebrow}>{stage.eyebrow} of {LESSON_STAGES.length}</p>
|
||||||
|
<h2 className={styles.title}>{stage.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={isHangry ? styles.hangryMood : styles.mood} aria-live="polite">
|
||||||
|
<span>
|
||||||
|
<strong>{stage.showFood && !hasReachedFood ? 'Food is coming' : mood.label}</strong>
|
||||||
|
<small>{stage.copy}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sidePanel}>
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span>Blood sugar</span>
|
||||||
|
<strong>{glucoseNow}<small> mg/dL</small></strong>
|
||||||
|
</div>
|
||||||
|
{stage.showFood ? (
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span>{stage.showSnack ? 'Snack' : 'Food event'}</span>
|
||||||
|
<strong>{stage.showSnack ? (hasReachedSnack ? 'added' : 'craving') : hasReachedFood ? 'absorbing' : 'up next'}</strong>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span>Baseline</span>
|
||||||
|
<strong>{TARGET_GLUCOSE}<small> mg/dL</small></strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stage.showInsulin ? (
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span>Insulin</span>
|
||||||
|
<strong>{insulinNow}<small> signal</small></strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{stage.showDelay ? (
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span>Delay</span>
|
||||||
|
<strong>{Math.round(params.responseDelay)}<small> min</small></strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.causalChain} aria-label="Causal chain">
|
||||||
|
<div className={activeStep === 'food' ? styles.activeCausalStep : styles.causalStep}>
|
||||||
|
<span>Food</span>
|
||||||
|
<strong>{Math.round(sample.food)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={activeStep === 'sense' ? styles.activeCausalStep : styles.causalStep}>
|
||||||
|
<span>Old sugar</span>
|
||||||
|
<strong>{sensedNow}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={activeStep === 'insulin' ? styles.activeCausalStep : styles.causalStep}>
|
||||||
|
<span>Insulin</span>
|
||||||
|
<strong>{insulinNow}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={activeStep === 'action' ? styles.activeCausalStep : styles.causalStep}>
|
||||||
|
<span>Action</span>
|
||||||
|
<strong>{uptakeNow}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={styles.controls}>
|
||||||
|
<button className={styles.button} disabled={lessonIndex === 0} onClick={previousStage} type="button">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button className={styles.button} onClick={togglePaused} onMouseEnter={() => safePlaySound(host, SOUND_IDS.hover)} type="button">
|
||||||
|
{paused ? 'Resume' : 'Pause'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.buttonPrimary} onClick={nextStage} type="button">
|
||||||
|
{lessonIndex === LESSON_STAGES.length - 1 ? 'Complete' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.dev-shell {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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('Simulation', buildLevaSchema() as Parameters<typeof useControls>[1]) as Record<string, unknown>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dev-shell">
|
||||||
|
<Leva hidden />
|
||||||
|
<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,80 @@
|
|||||||
|
import Component from './Component'
|
||||||
|
import type { GlitchComponentMetadata } from './types'
|
||||||
|
|
||||||
|
export default Component
|
||||||
|
|
||||||
|
export const metadata: GlitchComponentMetadata = {
|
||||||
|
|
||||||
|
name: 'bloodsugar',
|
||||||
|
displayName: 'Blood Sugar Delay Lab',
|
||||||
|
version: '1.0.0',
|
||||||
|
paramSchema: {
|
||||||
|
responseDelay: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'Feedback Lag',
|
||||||
|
description: 'Minutes of delay between glucose rising, insulin being secreted, and insulin action arriving.',
|
||||||
|
default: 34,
|
||||||
|
min: 0,
|
||||||
|
max: 80,
|
||||||
|
step: 1
|
||||||
|
},
|
||||||
|
mealLoad: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'Meal Size',
|
||||||
|
default: 44,
|
||||||
|
min: 20,
|
||||||
|
max: 80,
|
||||||
|
step: 1
|
||||||
|
},
|
||||||
|
pancreasStrength: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'Compensation Strength',
|
||||||
|
default: 1,
|
||||||
|
min: 0.45,
|
||||||
|
max: 1.6,
|
||||||
|
step: 0.01
|
||||||
|
},
|
||||||
|
playbackSpeed: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'Playback Speed',
|
||||||
|
default: 1,
|
||||||
|
min: 0.25,
|
||||||
|
max: 3,
|
||||||
|
step: 0.05
|
||||||
|
},
|
||||||
|
primaryColor: {
|
||||||
|
type: 'color',
|
||||||
|
label: 'Glucose Color',
|
||||||
|
default: '#6366f1'
|
||||||
|
},
|
||||||
|
accentColor: {
|
||||||
|
type: 'color',
|
||||||
|
label: 'Insulin Color',
|
||||||
|
default: '#22d3ee'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultParams: {
|
||||||
|
responseDelay: 34,
|
||||||
|
mealLoad: 44,
|
||||||
|
pancreasStrength: 1,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
primaryColor: '#6366f1',
|
||||||
|
accentColor: '#22d3ee'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
GlitchComponentProps,
|
||||||
|
GlitchComponentConfig,
|
||||||
|
GlitchComponentResult,
|
||||||
|
GlitchTheme,
|
||||||
|
GlitchHostBridge
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// CDN self-registration: when loaded as an IIFE script from the CDN, this registers
|
||||||
|
// the component in window.GlitchComponents so the host loader can retrieve it by id.
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
.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 !important;
|
||||||
|
width: min(100%, 1500px);
|
||||||
|
margin: 0 auto;
|
||||||
|
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: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10.4fr 5.6fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartPanel,
|
||||||
|
.controlPanel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--component-border);
|
||||||
|
border-radius: var(--border-radius, 12px);
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--component-primary) 12%, transparent), transparent 35%),
|
||||||
|
linear-gradient(180deg, var(--component-bg-secondary), var(--component-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartPanel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem 1rem 0.95rem 4.15rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
color-mix(in srgb, var(--component-bg) 76%, black);
|
||||||
|
background-size: 10% 100%, 100% 20%, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlPanel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1.35fr) auto auto auto;
|
||||||
|
gap: 0.72rem;
|
||||||
|
padding: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader,
|
||||||
|
.controlHeader {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
left: 4.15rem;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader strong {
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.happyChartBadge,
|
||||||
|
.hangryChartBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
font-family: var(--font-display, var(--font-main, system-ui, sans-serif));
|
||||||
|
font-size: 6.4rem;
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 28px rgba(34, 211, 238, 0.28);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartEmoji,
|
||||||
|
.hangryChartEmoji {
|
||||||
|
position: absolute;
|
||||||
|
top: 3.15rem;
|
||||||
|
right: 1.35rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 10.4rem;
|
||||||
|
height: 10.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--component-primary) 20%, rgba(10, 10, 15, 0.84));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--component-primary) 42%, transparent),
|
||||||
|
0 0 24px color-mix(in srgb, var(--component-primary) 24%, transparent);
|
||||||
|
font-size: 6.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangryChartEmoji {
|
||||||
|
animation:
|
||||||
|
hangryShake 0.24s linear infinite,
|
||||||
|
hangryBreath 1.1s ease-in-out infinite;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgba(248, 113, 113, 0.62), rgba(248, 113, 113, 0.18) 56%, rgba(10, 10, 15, 0.86) 72%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 rgba(248, 113, 113, 0.42),
|
||||||
|
0 0 22px rgba(248, 113, 113, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.happyChartBadge {
|
||||||
|
color: color-mix(in srgb, var(--component-accent) 74%, white);
|
||||||
|
opacity: 0.46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangryChartBadge {
|
||||||
|
animation:
|
||||||
|
hangryBadgeShake 0.26s linear infinite,
|
||||||
|
hangryPulse 1.35s ease-in-out infinite;
|
||||||
|
background: rgba(248, 113, 113, 0.14);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(248, 113, 113, 0.38),
|
||||||
|
0 0 22px rgba(248, 113, 113, 0.42);
|
||||||
|
color: #fecaca;
|
||||||
|
text-shadow:
|
||||||
|
0 0 18px rgba(248, 113, 113, 0.7),
|
||||||
|
0 0 42px rgba(248, 113, 113, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
color: var(--component-accent);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--component-text);
|
||||||
|
font-family: var(--font-display, var(--font-main, system-ui, sans-serif));
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood,
|
||||||
|
.hangryMood {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1.15rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--component-border) 84%, white);
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
background: color-mix(in srgb, var(--component-bg-secondary) 82%, black);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangryMood {
|
||||||
|
animation: hangryPanelPulse 1.4s ease-in-out infinite;
|
||||||
|
border-color: rgba(248, 113, 113, 0.64);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 32%, rgba(248, 113, 113, 0.34), transparent 38%),
|
||||||
|
color-mix(in srgb, var(--component-bg-secondary) 78%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji,
|
||||||
|
.hangryEmoji {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 5.4rem;
|
||||||
|
height: 5.4rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--component-primary) 18%, transparent);
|
||||||
|
font-size: 3.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangryEmoji {
|
||||||
|
animation:
|
||||||
|
hangryShake 0.24s linear infinite,
|
||||||
|
hangryBreath 1.1s ease-in-out infinite;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgba(248, 113, 113, 0.62), rgba(248, 113, 113, 0.16) 56%, transparent 72%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 rgba(248, 113, 113, 0.42),
|
||||||
|
0 0 22px rgba(248, 113, 113, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood strong,
|
||||||
|
.mood small,
|
||||||
|
.hangryMood strong,
|
||||||
|
.hangryMood small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood small,
|
||||||
|
.hangryMood small {
|
||||||
|
max-width: 32rem;
|
||||||
|
margin: 0.48rem auto 0;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-size: 1.28rem;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood strong,
|
||||||
|
.hangryMood strong {
|
||||||
|
font-family: var(--font-display, var(--font-main, system-ui, sans-serif));
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangryMood strong {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneLabels {
|
||||||
|
position: absolute;
|
||||||
|
top: 4.2rem;
|
||||||
|
bottom: 3.2rem;
|
||||||
|
left: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 2.65rem;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 2.65rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.09);
|
||||||
|
stroke-width: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.targetLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.42);
|
||||||
|
stroke-dasharray: 1.4 1.4;
|
||||||
|
stroke-width: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glucoseLine,
|
||||||
|
.insulinLine,
|
||||||
|
.responseLine {
|
||||||
|
fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glucoseLine {
|
||||||
|
stroke: var(--component-primary);
|
||||||
|
stroke-width: 2.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insulinLine {
|
||||||
|
stroke: var(--component-accent);
|
||||||
|
stroke-width: 2.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseLine {
|
||||||
|
stroke: #fbbf24;
|
||||||
|
stroke-dasharray: 3 2;
|
||||||
|
stroke-width: 1.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursorLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.32);
|
||||||
|
stroke-width: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delayLine {
|
||||||
|
stroke: #fbbf24;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delayCallout {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(251, 191, 36, 0.42));
|
||||||
|
}
|
||||||
|
|
||||||
|
.delayText {
|
||||||
|
fill: #fef3c7;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: rgba(10, 10, 15, 0.86);
|
||||||
|
stroke-width: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foodLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.3);
|
||||||
|
stroke-dasharray: 1.6 1.6;
|
||||||
|
stroke-width: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mealWindow {
|
||||||
|
fill: rgba(251, 113, 133, 0.1);
|
||||||
|
stroke: rgba(251, 113, 133, 0.24);
|
||||||
|
stroke-width: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snackWindow {
|
||||||
|
fill: rgba(251, 191, 36, 0.12);
|
||||||
|
stroke: rgba(251, 191, 36, 0.32);
|
||||||
|
stroke-width: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snackLine {
|
||||||
|
stroke: rgba(251, 191, 36, 0.54);
|
||||||
|
stroke-dasharray: 1.6 1.6;
|
||||||
|
stroke-width: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foodMarker {
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-size: 7.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glucoseDot,
|
||||||
|
.insulinDot,
|
||||||
|
.responseDot {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
stroke: var(--component-bg);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glucoseDot {
|
||||||
|
fill: var(--component-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insulinDot {
|
||||||
|
fill: var(--component-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseDot {
|
||||||
|
fill: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 0.95rem;
|
||||||
|
left: 4.15rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.38rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 0.28rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glucoseKey {
|
||||||
|
background: var(--component-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insulinKey {
|
||||||
|
background: var(--component-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseKey {
|
||||||
|
background: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foodKey {
|
||||||
|
background: #fb7185;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snackKey {
|
||||||
|
background: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidePanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric,
|
||||||
|
.delayCard {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.78rem;
|
||||||
|
border: 1px solid var(--component-border);
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
background: color-mix(in srgb, var(--component-bg-secondary) 84%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span,
|
||||||
|
.delayCard span {
|
||||||
|
display: block;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong,
|
||||||
|
.delayCard strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
color: var(--component-text);
|
||||||
|
font-size: 1.52rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric small {
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delayCard {
|
||||||
|
border-color: color-mix(in srgb, #fbbf24 42%, var(--component-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.delayCard p {
|
||||||
|
display: none;
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-size: clamp(0.72rem, 1vw, 0.92rem);
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalChain {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.38rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalStep,
|
||||||
|
.activeCausalStep {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.55rem 0.42rem;
|
||||||
|
border: 1px solid var(--component-border);
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
background: color-mix(in srgb, var(--component-bg-secondary) 86%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalStep:not(:last-child)::after,
|
||||||
|
.activeCausalStep:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -0.36rem;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
content: '>';
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeCausalStep {
|
||||||
|
border-color: color-mix(in srgb, var(--component-accent) 72%, white);
|
||||||
|
background: color-mix(in srgb, var(--component-accent) 18%, var(--component-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalStep span,
|
||||||
|
.activeCausalStep span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--component-text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalStep strong,
|
||||||
|
.activeCausalStep strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--component-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 0.9fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.buttonPrimary {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
padding: 0.78rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition:
|
||||||
|
transform var(--transition, 0.2s ease),
|
||||||
|
opacity var(--transition, 0.2s ease),
|
||||||
|
border-color var(--transition, 0.2s ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 1px solid var(--component-border);
|
||||||
|
background: var(--component-bg-secondary);
|
||||||
|
color: var(--component-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--component-primary) 68%, white);
|
||||||
|
background: var(--component-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover,
|
||||||
|
.buttonPrimary:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled,
|
||||||
|
.buttonPrimary:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hangryShake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: translateX(-1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateX(1px) rotate(1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: translateX(-0.5px) rotate(-0.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hangryBadgeShake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: translate(calc(-50% - 2px), -50%) rotate(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(calc(-50% + 2px), -50%) rotate(1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: translate(calc(-50% - 1px), -50%) rotate(-0.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hangryBreath {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0 rgba(248, 113, 113, 0.34),
|
||||||
|
0 0 18px rgba(248, 113, 113, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.75rem rgba(248, 113, 113, 0),
|
||||||
|
0 0 30px rgba(248, 113, 113, 0.64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hangryPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.76;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hangryPanelPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(248, 113, 113, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(248, 113, 113, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: portrait), (max-width: 820px) {
|
||||||
|
.root {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: clamp(0.7rem, 2vw, 1rem);
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartPanel,
|
||||||
|
.controlPanel {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlPanel {
|
||||||
|
grid-template-rows: auto minmax(0, 1.25fr) auto auto auto;
|
||||||
|
gap: 0.58rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartPanel {
|
||||||
|
padding: 0.85rem 0.85rem 0.78rem 3.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader,
|
||||||
|
.legend {
|
||||||
|
left: 3.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartEmoji,
|
||||||
|
.hangryChartEmoji {
|
||||||
|
top: 2.75rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 8.7rem;
|
||||||
|
height: 8.7rem;
|
||||||
|
font-size: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader strong,
|
||||||
|
.legend {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidePanel {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood,
|
||||||
|
.hangryMood {
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood strong,
|
||||||
|
.hangryMood strong {
|
||||||
|
font-size: 1.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood small,
|
||||||
|
.hangryMood small {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.happyChartBadge,
|
||||||
|
.hangryChartBadge {
|
||||||
|
font-size: 4.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric,
|
||||||
|
.delayCard {
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong,
|
||||||
|
.delayCard strong {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalChain {
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalStep,
|
||||||
|
.activeCausalStep {
|
||||||
|
padding: 0.38rem 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.chartPanel {
|
||||||
|
padding: 0.72rem 0.68rem 0.7rem 2.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader,
|
||||||
|
.legend {
|
||||||
|
left: 2.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartEmoji,
|
||||||
|
.hangryChartEmoji {
|
||||||
|
top: 2.28rem;
|
||||||
|
right: 0.78rem;
|
||||||
|
width: 6.5rem;
|
||||||
|
height: 6.5rem;
|
||||||
|
font-size: 3.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader {
|
||||||
|
top: 0.72rem;
|
||||||
|
right: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartHeader strong,
|
||||||
|
.legend {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
right: 0.68rem;
|
||||||
|
bottom: 0.68rem;
|
||||||
|
gap: 0.32rem 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneLabels {
|
||||||
|
left: 0.55rem;
|
||||||
|
width: 1.85rem;
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.happyChartBadge,
|
||||||
|
.hangryChartBadge {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood,
|
||||||
|
.hangryMood {
|
||||||
|
gap: 0.38rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood strong,
|
||||||
|
.hangryMood strong {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood small,
|
||||||
|
.hangryMood small {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidePanel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric,
|
||||||
|
.delayCard {
|
||||||
|
padding: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span,
|
||||||
|
.delayCard span {
|
||||||
|
font-size: 0.56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong,
|
||||||
|
.delayCard strong {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.causalChain {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-template-columns: 0.8fr 0.9fr 1fr;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.buttonPrimary {
|
||||||
|
padding: 0.52rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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,36 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
|
|
||||||
|
// IIFE format: component registers itself in window.GlitchComponents at load time.
|
||||||
|
// react/jsx-runtime is intentionally NOT external — it gets bundled and reads from window.React.
|
||||||
|
// Host apps must expose window.React and window.ReactDOM before loading any component script.
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [react(), cssInjectedByJsPlugin()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.tsx'),
|
||||||
|
name: 'GlitchComponent',
|
||||||
|
fileName: () => 'bloodsugar.js',
|
||||||
|
formats: ['iife']
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM',
|
||||||
|
},
|
||||||
|
assetFileNames: 'assets/[name][extname]',
|
||||||
|
exports: 'named'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
minify: mode === 'production'
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
open: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user