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
- Embed a baseline in the app bundle, so first launch needs no network.
- Check occasionally — once per 24 h, jittered per install, skipped on metered connections. Never on every launch.
- 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-motionand re-checks it at runtime, not just on construction — the custom elements, the React and Vue components and both cloud adapters. PassingreducedMotionexplicitly overrides the OS in both directions; omitting it means "follow the setting". - Every instance follows
prefers-color-schemefor the ground its props are drawn on, overridable withtheme. - Web instances pause when scrolled out of view (
IntersectionObserver). React Native has no equivalent, on either renderer — usefpsandpausedfor a list cell. See PARITY.md. - On React Native, the renderer is the performance decision:
react-native-svgbuilds 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 bynpm run parity. - One shared
requestAnimationFrameloop 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
labelwhen it carries information; the default is"kluuu mascot". - Clouds are
aria-hiddenand 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.