Docs

Integration guide

Everything needed to put the mascot in an app: install, render, drive it from events, and pin a version.

Rendered from docs/INTEGRATION.md in the repo. Editing that file changes this page.

Current links

kluuu — integration guide

How to put the mascot and the scenery into an app, drive them, and keep them up to date. This is a reference for calling things; it does not explain how they are built.

  • Base URL: https://kluuuapi.glade.studio (configurable — see Changing the host)
  • Schema major: 1 (in the URL path)
  • Spec version: 1.9.0 (semver, in the payload)

Two things ship in this system:

What it is Driven by
Mascot The brain-cloud character A mood id
Scenery Ambient background pieces. Today: clouds Parameters

1. Install

npm i https://kluuuapi.glade.studio/api/download/package

There is no registry entry, and there will not be one. @kluuu/mascot is not published to npm, so npm i @kluuu/mascot returns 404. It is served as a tarball from this host instead, and npm i <tarball url> is a first-class install: it resolves, it writes a URL into your lockfile, and it needs no registry credentials and no .npmrc.

That URL always redirects to the current release. To pin one, install the versioned file it redirects to:

npm i https://kluuuapi.glade.studio/embed/kluuu-mascot-1.9.0.tgz

Pin in anything that runs npm ci. npm records a subresource integrity hash for a remote tarball keyed by its URL, so if the bytes behind the unversioned URL change under you, npm ci fails with EINTEGRITY until the lockfile is refreshed. The versioned URL never changes bytes.

ESM-only. Node 18+ / any modern bundler. React, Vue, react-native, react-native-svg and @shopify/react-native-skia are optional peers — install none of them if you use the custom elements, and on React Native install whichever of the last two matches the renderer you pick (see §2).

Entry points

Import What you get
@kluuu/mascot createMascot, createClouds, the controllers, scene builders, the full spec
@kluuu/mascot/define Registers <kluuu-mascot> and <kluuu-clouds> (side effect only)
@kluuu/mascot/element The mascot element class, unregistered
@kluuu/mascot/react <KluuuMascot> for React 18+
@kluuu/mascot/vue <KluuuMascot> for Vue 3.4+
@kluuu/mascot/react-native <KluuuMascot> for React Native, drawn with react-native-svg
@kluuu/mascot/react-native-skia The same <KluuuMascot>, drawn with Skia
@kluuu/mascot/clouds buildClouds, CloudsController, createClouds, the scenery spec
@kluuu/mascot/clouds/react <KluuuClouds> for React
@kluuu/mascot/clouds/vue <KluuuClouds> for Vue
@kluuu/mascot/clouds/react-native <KluuuClouds> for React Native, drawn with react-native-svg
@kluuu/mascot/clouds/react-native-skia The same <KluuuClouds>, drawn with Skia
@kluuu/mascot/spec Moods, rig descriptors, scenery descriptors, motion constants — no DOM
@kluuu/mascot/client The OTA spec client
@kluuu/mascot/geometry.json The traced 1024×1024 artboard, as data

@kluuu/mascot also exports the spec registry — installSpec, installedSpec, applySpec, resetSpec, onSpecChange, BASELINE_SPEC — and re-exports the OTA client, so a page that ships the embed bundle has the whole update path in one file. See §7.

@kluuu/mascot/spec is DOM-free: server code that needs to know which moods exist should not pull in a renderer.


2. Render the mascot

Custom element (framework-agnostic)

<script type="module">
  import '@kluuu/mascot/define'
</script>

<kluuu-mascot mood="happy" size="160" idle="subtle"></kluuu-mascot>
Attribute Values Default Notes
mood any mood id idle Springs across; never cuts
size number or CSS length — 160 and 160px both work
idle off | subtle | lively subtle Ambient motion level
speed 0.25–2 1 Softens every spring
seed integer position in DOM Per-instance wobble; even and odd seeds mirror each other
colour-mode mood | brand mood brand keeps every mood in the registered mint
paused boolean absent Freezes without unmounting
fps number 0 Cap the update rate. 0 is every frame the display offers
background CSS colour — Painted behind the mascot, filling the artboard
theme light | dark follows the OS The ground props are drawn for
reduced-motion true | false follows the OS Absent follows prefers-reduced-motion; false forces motion on
label string kluuu mascot Accessible name

Fires moodchange (a CustomEvent with detail.mood) when a transition settles.

React

import { KluuuMascot } from '@kluuu/mascot/react'

<KluuuMascot mood="celebrating" size={160} onMoodChange={(id) => {}} />

Vue / Nuxt

<script setup>
import { KluuuMascot } from '@kluuu/mascot/vue'
</script>

<template>
  <KluuuMascot mood="thinking" :size="160" />
</template>

React Native

Two renderers, one component. Same props, same imperative handle, same frames — the import path is the whole difference. Prefer Skia; use the SVG one if you would rather not add a native module.

npm i https://kluuuapi.glade.studio/api/download/package @shopify/react-native-skia
import { KluuuMascot } from '@kluuu/mascot/react-native-skia'

<KluuuMascot mood="celebrating" size={160} />
npm i https://kluuuapi.glade.studio/api/download/package react-native-svg
import { KluuuMascot } from '@kluuu/mascot/react-native'

<KluuuMascot mood="celebrating" size={160} fps={30} />

Why the SVG one takes an fps, and why the Skia one does not need it: under react-native-svg every node of the scene is a host component with a native view behind it — 194 of them for a busy mood — rebuilt and re-committed through Fabric every frame. That is the ceiling, and it is the view tree rather than the mascot: the rig is a few hundred flops and the scene builder is the same function the web runs uncapped at 60. React Native has no imperative escape hatch under the New Architecture (setNativeProps is deprecated), so fps={30} is the right tool and 30 is a sensible number on a mid-range Android phone.

Skia has no view tree. A frame is recorded into one SkPicture and replayed on the render thread, so React sees a single element whose single prop changed, and 60 is affordable on both platforms. fps still exists there — a screen with a dozen decorative mascots may still have an opinion — but it defaults to 0.

Switching is an import change and nothing else. If you have both peers installed, you can even use the two side by side.

Imperative

import { createMascot } from '@kluuu/mascot'

const mascot = createMascot(document.querySelector('#slot'), { mood: 'idle' })
mascot.setMood('celebrating')
mascot.setParams({ bodyRotate: 4 })   // drive channels directly
mascot.blink()
mascot.destroy()

3. Render the clouds

A drifting band of procedural clouds. It fills its container, is aria-hidden and never intercepts pointer events — it is a background.

Custom element

<kluuu-clouds preset="soft" style="height: 220px"></kluuu-clouds>

React / Vue / React Native

import { KluuuClouds } from '@kluuu/mascot/clouds/react'

<KluuuClouds preset="dusk" cover={0.5} drift={12} />

The band ships in the same two React Native flavours as the mascot — @kluuu/mascot/clouds/react-native and @kluuu/mascot/clouds/react-native-skia — with the same props and the same fps default of 30.

<KluuuClouds preset="night" :depth="4" />

Imperative

import { createClouds } from '@kluuu/mascot/clouds'

const sky = createClouds(el, { preset: 'soft', frameW: 1600, frameH: 400 })
sky.setParams({ cloudHue: 210 })
sky.destroy()

Presets

id Looks like
soft White cumulus on a transparent ground, phone-shaped. The default.
banner 1600×400: flatter, wider, slower — a band under a headline.
outlined Contoured, for surfaces that already have a lot of soft edges.
dusk Warmer, lower, hazier toward the back.
night Deep and low-contrast, for a dark surface.
fill The band risen to fill the frame in one flat colour — a destination to transition into, not a sky.
launchpad The default daytime sky with the rocket in it, waiting. launch stays at 0: a preset is a place, not an event.

A preset is a starting point: every parameter below still overrides it. Every preset is transparent — none of them paints a sky. A cloud band sits on whatever you have already drawn, and a preset that shipped its own opaque rectangle would silently cover it up. skyL/skyC/skyHue still matter, because haze recedes toward them; raise skyOpacity if you want the rectangle actually painted.

Parameters

Pass any of these as a prop, an attribute (kebab-cased: frameW → frame-w), or a query parameter. Values outside the range are clamped, not rejected.

Parameter Range Default What it does
frameW 240–4096 402 Artboard width. The default is a phone screen
frameH 120–2048 872 Artboard height
cover 0.05–1 0.32 How far up the frame the band reaches at zoom 1, measured to the crest of the backmost layer. Purely vertical — it never resizes the puffs
zoom 0.2–3 0.57 Overall scale. Multiplies the band height, the puff radius, all three stroke weights and the drift together
depth 1–6 3 Parallax layers, back to front
layerGap 0–1 0.08 Distance between layers, as a fraction of the cover height. 0 stacks them flush; 1 drops the front layer to the bottom of the band
scale 0.1–2.5 1.35 Puff size only — the band keeps its height and changes grain. Drop it toward 0.5 for a fine, foamy edge; raise it on a short, wide banner
softness 0–1 0.75 0 = foamy scalloped edge, 1 = long rolling contour
haze 0–2 1.17 How far the back layers recede toward the sky. Past 1 they keep going; near 1.8 the backmost reaches the sky's lightness and chroma. Hue lags on purpose, so the dissolve is exact when skyHue equals cloudHue and leaves a faint tint when it does not
rocket 0–1 0 The rocket. Presence, not a switch: values in between are the arrival, which is a rise from behind the band rather than a fade — a solid object either is behind a cloud or it is not. It draws behind the frontmost cloud layer, so the clouds hide it on the pad and the launch climbs out of them. The mascot rides in the porthole: loading while it waits, excited once the boosters light
launch 0–1 0 The launch. 0 is sitting on the pad under a pilot flame; the first fifth lights the boosters and shakes the rocket without moving it; the rest is an accelerating climb that carries it off the top of the frame by 1. Drive it yourself — it is the one channel that is not sprung, because a spring on a timeline lags the story it is telling and never quite arrives. Past the end, set rocket back to 0 and the sky you started with is what is left
items 0–2 0 Flying things. How often books, pencils, flashcards, stars and question marks arc out of the clouds and drop back. Off by default — turn it on per screen; 1 is a steady stream. They only ever appear behind a layer, never in front of the frontmost one, so a single-layer band shows none
seed 0–9999 0 Which skyline. 0 draws a new one on every load; 1–9999 pin it
drift −400–400 49 Front-layer speed, units/second. The sign reverses the direction. Layers behind move proportionally slower
cloudL / cloudC / cloudHue 0.4–1 / 0–0.2 / 0–360 0.985 / 0.014 / 236 Cloud colour, OKLCH
outlineDL / outlineW −0.4–0 / 0–24 −0.09 / 0 Outline: lightness step, stroke weight. Width 0 = none
innerDL / innerSize −0.4–0 / 0–96 -0.038 / 7 Inner shadow inside the top edge. This is the rim you see on soft and night, which draw no outline
accentDL / accentW 0–0.2 / 0–24 0.02 / 0 Rim highlight on the top edge. Width 0 = none
skyL / skyC / skyHue 0.93 / 0.06 / 236 Flat sky colour, OKLCH
skyOpacity 0–1 0 Sky rectangle opacity. 0 by default

Colours are OKLCH: L lightness, C chroma, Hue degrees. The DL values are steps from the cloud colour, so an outline can never end up lighter than the cloud it outlines.

Sizing: zoom vs cover vs scale. Three dials that do not overlap:

  • cover — where the band sits. Purely vertical. Moves the whole thing up and down the frame without resizing anything.
  • scale — how big one cloud is. Grain only. The band keeps its height.
  • zoom — everything at once. Band height, puff radius, all three stroke weights and the drift speed, so the picture stays itself and only gets nearer or further away.

Reach for zoom when a layout gives you a different amount of room, and for the other two when you want the band to look different.

Transitions. Every parameter is a spring, in the runtime and on native alike: assigning a new value moves the band there rather than cutting to it, and that applies to a preset switch, a single slider and a restored state equally. Switching from soft to fill is the band climbing the frame while the colour crosses under it — one movement, about 1.2 seconds. A change made while another is still settling keeps its velocity and simply re-aims, so interruptions read as one motion rather than two.

Two channels cannot be averaged and transition anyway: seed crossfades the puff lattice rather than the number, and depth grows a fractional layer that fades in. The one step that is not smooth is collapsing to a single layer, because a lone layer is the foreground by definition.

The image endpoint is unaffected — it renders a settled configuration.

Random skies. seed defaults to 0, which means pick a new skyline every time this thing is created. A mounted element, a React component, a SwiftUI view and an HTTP request each count as one load and each get their own sky. Pin any value from 1 to 9999 to freeze it:

<kluuu-clouds></kluuu-clouds>          <!-- a new sky on every page load -->
<kluuu-clouds seed="4821"></kluuu-clouds>  <!-- always this exact sky -->

The randomness is confined to picking the number. The drawing itself is a pure function of (parameters, time) on every platform, so a given seed renders the same picture from the server, the browser and the phone.

skyOpacity defaults to 0 because the usual arrangement is your own gradient behind a transparent cloud band:

<div style="background: linear-gradient(#7FC4F5, #DFF1FF)">
  <kluuu-clouds preset="soft" style="height: 260px"></kluuu-clouds>
</div>

The descriptor table is available at runtime if you want to build controls from it — CLOUD_PARAMS from @kluuu/mascot/spec, and scenery.clouds.params in the spec bundle. Every row carries id, min, max, default, step, label and describe.


4. Drive the mascot from app events

Name events, not animations, and let the mapping live in the spec:

import { EVENT_MOOD } from '@kluuu/mascot/spec'

mascot.setMood(EVENT_MOOD.answer_correct)            // 'happy'
mascot.setMood(EVENT_MOOD.session_complete_perfect)  // 'perfectScore'

If your code says setMood('happy'), changing that decision is a code release. If it says EVENT_MOOD.answer_correct, it is a spec update.

Group Events
Session answer_correct, answer_correct_streak, answer_wrong, answer_wrong_repeated, quiz_generating, quiz_answering, study_stuck, time_saved, notes_uploading, session_complete_high, session_complete_perfect, session_complete_low, exam_started, streak_broken, returning_user, idle_short, idle_long, error_network, error_fatal, empty_state
Onboarding — country onboarding_country_de, onboarding_country_at, onboarding_country_ch, onboarding_country_gb, onboarding_country_fr, onboarding_country_it, onboarding_country_es, onboarding_country_other, onboarding_location
Onboarding — school onboarding_school_high, onboarding_school_vocational, onboarding_school_university, onboarding_school_applied, onboarding_school_other
Onboarding — goal goal_better_grades, goal_consistency, goal_exam_soon
Referral source_instagram, source_tiktok, source_friend, source_school, source_app_store, source_search
Method & skins method_active_recall, skin_turtle, skin_goldfish, skin_lion, skin_bee

Country keys are ISO 3166-1 alpha-2, not language tags. onboarding_country_other resolves to worldwide, which carries a globe rather than an eighth flag.

Mood groups

Eleven groups. Build a gallery from MOOD_GROUPS and GROUP_LABELS rather than listing ids by hand.

Group What it is
positive, calm, cognitive, negativeLow, negativeHigh The emotional core
special Strong signatures: on fire, dizzy, mischief
product In-app states: generating a quiz, reading notes, active recall, browsing saved quizzes, starting a new one, being stuck, hours saved
onboarding Country, school type, goal
referral Where they heard about it
creature Mascot skins: turtle, goldfish, lion, bee
tier Rank badges: copper through rainbow — see below

Rank tiers

Eight ranks — copper, bronze, silver, gold, platinum, emerald, diamond, rainbow. The mascot turns toward a hexagonal medallion in its own material, with the rank stated under it as a row of pips, and its body takes the material's colour: lightness and chroma carry far more of a reward than hue does, so a mascot that has itself turned to gold reads stronger than one merely holding a gold object.

mascot.setMood('tierPlatinum')
mascot.emit('tier_platinum')   // the same thing, by event

Composure rises copper → gold, dips at platinum, then climbs again. That is deliberate and not a mistake to tidy up: gold is the loudest anyone gets about a metal, platinum is the first rank too expensive to shout about, and without the dip the top four are indistinguishable in motion.

Driving channels directly

setParams writes rig channels on top of the current mood. Three worth knowing:

// A flag or its garnish, by country code.
mascot.setParams({ prop: 'flag', propVariant: 'gb', propAmount: 1 })

// A rank badge, by material. One prop, eight designs.
mascot.setParams({ prop: 'rankBadge', propVariant: 'emerald', propAmount: 1 })

// A marking painted inside the silhouette.
mascot.setParams({ pattern: 'shell', patternAmount: 1 })

pattern is none · shell · scales · stripes. propVariant accepts auto · stars · icons · plain · marked · de · at · ch · gb · fr · it · es.


5. Image endpoints

For anywhere that accepts an image URL and will never run a script — an <img>, a README, a CMS, an email-adjacent template.

GET /api/mascot/<mood>.svg     one mood, idle-animating
GET /api/clouds                a drifting cloud band
GET /api/clouds.svg            the same, for hosts that need the extension

Both send Access-Control-Allow-Origin: * and Cache-Control: public, max-age=3600, stale-while-revalidate=604800 (the one exception is /api/clouds with no seed, which must vary per request — see below).

/api/mascot/<mood>.svg

Query Values Default
size 16–2048 512
bg hex, without # transparent
colour mood | brand mood
idle off | subtle | lively subtle
seed 1–9999 1
frames 6–60 24
duration 500–20000 ms 4000
<img src="https://kluuuapi.glade.studio/api/mascot/celebrating.svg?size=240" alt="" />

Motion that is a transform or an opacity animates correctly. A prop whose shape changes frame to frame — the fire's flicker, confetti tumbling — is sampled at one instant and held. Use the runtime when a prop must flicker.

/api/clouds

Accepts preset plus every parameter from the table in §3, by the same name. Add t=<ms> for a single still frame instead of an animation.

<img src="https://kluuuapi.glade.studio/api/clouds?preset=dusk&frameW=1600&frameH=400" alt="" />
/api/clouds?cloudHue=210&depth=4&drift=-24
/api/clouds?preset=night&frameW=2400&frameH=600
/api/clouds?zoom=1.6&layerGap=0.55            # bigger, and more spread out
/api/clouds?t=4200                            # still frame

Caching and the seed. With no seed the endpoint rolls a new sky per request and answers Cache-Control: no-store, so an <img> really does get a different sky each load. Pin seed=1…9999 and it becomes a pure function of the URL, cached for an hour with a week of stale-while-revalidate. Every response carries the seed it used in X-Kluuu-Seed, so you can pin one you liked.

The drift is emitted as a CSS keyframe per layer, so the animation loops forever without repeating a sampled sequence, and it stops under prefers-reduced-motion. An unknown preset returns 400; an out-of-range number is clamped.


6. The spec API

GET /spec/v1/index.json                       pointer, ~1 KB
GET /spec/v1/releases/<version>/bundle.json   immutable release
GET /spec/v1/schema.json                      JSON Schema
Endpoint Cache-Control Revalidation
index.json public, max-age=300, stale-while-revalidate=86400 strong ETag → 304
releases/*/bundle.json public, max-age=31536000, immutable never changes
schema.json public, max-age=31536000, immutable never changes

An update check that finds nothing new costs one 304 with no body. Send the ETag you stored:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'If-None-Match: "<stored-etag>"' \
  https://kluuuapi.glade.studio/spec/v1/index.json
# → 304

index.json

{
  "schema": "kluuu.mascot.index/1",
  "latest": {
    "version": "1.9.0",
    "url": "https://kluuuapi.glade.studio/spec/v1/releases/1.9.0/bundle.json",
    "sha256": "0b0733da…"
  },
  "channels": { "stable": "1.9.0", "next": "1.9.0" }
}

bundle.json

$schema    URL of the JSON Schema
spec       "kluuu.mascot.spec/v1"
version    "1.9.0"
channel    "v1"
geometry   1024×1024 artboard: lobes, cusps, face, mouth, sulci, highlights
rig        { params: [descriptors], defaults, enums }
motion     { springs, channelSpring, easing, duration, idle, follow, asymmetry, squash }
moods      { groups, groupLabels, events, list: [90 moods] }
scenery    { clouds: { params: [descriptors], defaults, presets } }

rig.params and scenery.clouds.params are self-describing. The studio builds its entire control panel from them, and so can you.

Ignore keys you do not recognise. That is the rule that lets a new mood, a new channel or a new scenery piece ship without breaking deployed clients.


7. Over-the-air updates

  1. Embed a baseline in the app bundle, so first launch needs no network.
  2. Check occasionally — once per 24 h, jittered per install, skipped on metered connections. Never on every launch.
  3. Swap atomically after verifying signature and hash. If the stored spec fails to parse on the next boot, fall back to the embedded one.
import { createMascot, installSpec, applySpec } from '@kluuu/mascot'
import { createSpecClient, confirmSpecHealthy } from '@kluuu/mascot/client'
import embedded from './kluuu-spec-1.9.0.json'

// Install the baseline before the first frame. Everything built after this
// renders from it, and nothing has touched the network yet.
installSpec(embedded)

const client = createSpecClient({
  baseUrl: 'https://kluuuapi.glade.studio',
  embedded,
  storage: localStorage,   // AsyncStorage on RN, UserDefaults on iOS
  publicKeys: { k1: '<32-byte base64>' },
  // Off by default. See "Opting in", below.
  policy: { enabled: true },
})

const mascot = createMascot(el, { mood: 'idle' })

// Tell the client this build renders. See "Confirming the boot", below.
confirmSpecHealthy()

// Fire-and-forget, off the critical path.
client.checkForUpdate().then((r) => {
  if (r.updated) applySpec(mascot, client.current())
})

The payload is JSON, never JavaScript. The client parses and validates it; it never evaluates it. A new mood ships without a store release; a new renderer does not.

installSpec and applySpec

installSpec(bundle) resolves and installs a spec process-wide. Every controller built afterwards reads it, so this is what you call at boot.

applySpec(instance, bundle) does that and hands the spec to one live instance, in that order. Springs keep their value and their velocity, so a mascot mid-transition travels to the new pose rather than cutting to it. Use it for the case the name describes — an update that lands while something is on screen. In production the safest shape is still installSpec at boot with everything constructed after it.

Neither throws. Both return { ok, spec, problems }, where spec is what is active after the call: a payload that fails validation is refused whole and changes nothing. A mood id the new spec no longer declares falls back to idle and then to the first mood, so it can never be undefined.

onSpecChange(fn) subscribes to installs and returns an unsubscribe. The framework adapters already use it, which is why an install reaches a mounted <KluuuMascot> without a remount. resetSpec() goes back to BASELINE_SPEC, which is built from the compiled tables and is never mutated or discarded.

What a shipped renderer can adopt, and what it cannot. New moods, retuned motion constants, changed rig defaults and new geometry all take effect from data. A new rig channel or a new prop id does not: those need drawing code, and a build that has never heard of a prop coerces the unknown name back to none — the same coerce-and-skip rule the rig applies to every id it has not seen. Data ships over the air; renderers ship through a store.

Opting in

policy.enabled defaults to false and nothing touches the network until you pass policy: { enabled: true }. A mascot library reaching out from your app on your users' data plan is not a decision a library gets to make. Until you opt in, checkForUpdate() resolves to { updated: false, reason: 'auto-update disabled; pass policy: { enabled: true } to opt in' }.

Confirming the boot

Call confirmSpecHealthy() once the app has rendered successfully. It clears a boot counter. Without it a stored spec is discarded after two launches, by design: a spec that parsed fine when it was downloaded can still be the thing that breaks the next start, and a mascot that bricks the app on every launch is the worst failure this system has. The counter is what stops that, and calling the function is how you tell it the last one was fine.

Signing

Verification is only as real as the signature it checks. If your origin serves an unsigned index, publicKeys guarantees the update is refused — which is the correct behaviour and a confusing way to discover the server was never configured. To sign, generate a keypair and set the private half as a secret on the host:

node tools/keygen.mjs k1
# → the 32-byte base64 public key, for publicKeys
# → KLUUU_SIGNING_KEY / KLUUU_SIGNING_KEY_ID, as Cloudflare Pages *secrets*

With no key configured the index is served exactly as before, unsigned, which is right for preview deployments. With one set, /spec/v1/index.json carries keyId and signature over the canonical JSON of the rest of the document.

When verification fails, the reason says which failure it was, because the responses are opposites:

reason What happened What to do
unsigned the origin is not signing set KLUUU_SIGNING_KEY
untrusted-key signed by a keyId you do not trust rotate, or add the key
malformed the key or signature is not the right number of bytes check what you pasted
unsupported this runtime cannot do Ed25519 at all upgrade, or ship the fallback
forged the signature does not match the document somebody is tampering

cryptoBackend() reports which implementation answered — webcrypto or pure-js on the web and React Native, and the reason string reads cryptokit on Apple platforms. React Native has no crypto.subtle; it uses the bundled pure-TypeScript Ed25519 and needs no polyfill.

Setting Value
Base interval 24 h
Jitter ±25%, deterministic from install id
Index timeout 6 s
Bundle timeout 30 s
Max index size 64 KB
Max bundle size 2 MB
Max index age before forced refresh 30 days

8. Versioning

Two independent axes:

  • schemaMajor — integer, in the URL (/v1/). Changes only on a breaking change. /v1/ keeps working forever.
  • version — semver, in the payload.

What to expect from each:

Bump What can change What you must do
PATCH Retuned springs, colour changes under ΔL 0.02, re-exported geometry Nothing
MINOR New moods, props, glyphs, rig channels, scenery pieces; deprecations Nothing, if you ignore unknown keys
MAJOR Removed or renamed mood ids, a new coordinate system, changed field types Migrate; the old /v1/ keeps serving

Mood ids are permanent. A misspelled id is deprecated and aliased, never corrected:

{ "id": "celebrating", "$deprecated": "Use 'perfectScore'", "replacedBy": "perfectScore" }

Both ids resolve for at least 6 months or 2 store releases, whichever is longer.


9. Native

React Native is the same TypeScript with the leaf primitives swapped — for react-native-svg in one renderer and for Skia draw calls in the other. The rig, the scene builder, the props and the controller are shared with the web build, and everything the two renderers do apart from drawing is shared between them. See §2 and §3 for usage, and PARITY.md for which to pick.

SwiftUI is a hand port in native/swiftui. It decodes the same generated kluuu-spec.json rather than re-declaring the moods.

import KluuuMascot

MascotView(mood: "celebrating")
    .frame(width: 160, height: 160)

CloudsView(preset: "dusk")
    .frame(height: 220)

// Or with explicit parameters:
var sky = CloudParams()
sky.depth = 4
sky.cloudHue = 210
CloudsView(params: sky)

// The rocket, and its launch. `launch` is a position in a story rather than a
// quantity, so drive it from your own clock — a 60 Hz tick walking 0 to 1 over
// four seconds is the whole sequence. When it reaches 1 the rocket is off the
// top of the frame; drop `rocket` back to 0 and you are left with the sky you
// started with.
CloudsView(preset: "launchpad", overrides: ["rocket": 1, "launch": launch])

CloudsView fills its frame and scales the band to cover it, so it can be any aspect ratio without letterboxing.

Getting the SwiftUI package

Served as a zip of the repo's own sources, so what you download is what is running on the studio site:

curl -fL https://kluuuapi.glade.studio/api/download/swiftui -o kluuu-mascot-swiftui.zip
unzip kluuu-mascot-swiftui.zip
# then: add swiftui/ as a local Swift package, or copy Sources/KluuuMascot in
URL What
https://kluuuapi.glade.studio/api/download/swiftui The Swift package, zipped. iOS 16 / macOS 13.
https://kluuuapi.glade.studio/api/download/react-native All four React Native adapters — mascot and clouds, SVG and Skia — and every module they import, zipped. For vendoring.
https://kluuuapi.glade.studio/api/download/package The npm tarball. Redirects to the versioned file.
https://kluuuapi.glade.studio/api/download/parity PARITY.md, the per-feature parity table.

Do not write your own renderer against the spec JSON. The bundle is data — geometry, descriptors, motion constants, mood overrides. It contains no drawing code, so a renderer built from it alone is a new implementation with its own bugs and its own drift. If the download is unreachable, that is an outage to fix: the same package is in the repo at native/swiftui.


10. Embeddable bundle

Two files, downloadable from /export, for file:// and WebView hosts where a bare module specifier will not resolve:

File Size Contents
kluuu-mascot-<version>.js ~213 KB Runtime, ESM, zero imports
kluuu-spec-<version>.json ~83 KB Moods, geometry, motion, scenery
cd apps/studio && npm run embed

11. Accessibility and performance

  • Every instance respects prefers-reduced-motion and re-checks it at runtime, not just on construction — the custom elements, the React and Vue components and both cloud adapters. Passing reducedMotion explicitly overrides the OS in both directions; omitting it means "follow the setting".
  • Every instance follows prefers-color-scheme for the ground its props are drawn on, overridable with theme.
  • Web instances pause when scrolled out of view (IntersectionObserver). React Native has no equivalent, on either renderer — use fps and paused for a list cell. See PARITY.md.
  • On React Native, the renderer is the performance decision: react-native-svg builds a native view per node and is usually capped at 30, while the Skia renderer records one picture per frame and holds 60 on both platforms. Both draw the same picture — every mood, every preset and the whole rocket launch are diffed pixel by pixel against the web renderer by npm run parity.
  • One shared requestAnimationFrame loop drives every mascot and every cloud band on the page.
  • The DOM is patched attribute-by-attribute, not re-serialised.
  • Give the mascot a meaningful label when it carries information; the default is "kluuu mascot".
  • Clouds are aria-hidden and non-interactive. Do not give them a label.

12. Changing the host

Every URL here is written as https://kluuuapi.glade.studio. Reading this on the studio site, that placeholder is already replaced with the host you are reading it from, so the URLs are copy-pasteable. In the raw file it stays a placeholder.

The host is one value, set once at deploy time:

NUXT_PUBLIC_SPEC_BASE_URL=https://your-host.example.com

Leave it unset and every URL the app emits becomes origin-relative, which is what makes a local dev server document itself instead of pointing at production.

Clients take the base as a constructor argument, so pointing an app at a staging spec is a config change, not a rebuild.