Initial commit
This commit is contained in:
@@ -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" />
|
||||
Reference in New Issue
Block a user