feat(plugins): hero widget slot + Koffi reference plugin

Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.

Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.

Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).
This commit is contained in:
mauriceboe
2026-07-03 21:02:28 +02:00
committed by Maurice
parent 8c9476e487
commit 17f3c86967
22 changed files with 1196 additions and 188 deletions
+13 -2
View File
@@ -21,6 +21,7 @@ import {
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
import PluginWidgets from '../components/Plugins/PluginWidgets'
import PluginFrame from '../components/Plugins/PluginFrame'
import { usePluginStore } from '../store/pluginStore'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
@@ -127,7 +128,7 @@ export default function DashboardPage(): React.ReactElement {
const isAddonEnabled = useAddonStore(s => s.isEnabled)
const showCollections = isAddonEnabled('collections') && sideWidgets.collections
// Desktop has a master toggle for the whole right column; off → centered layout.
const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget')
const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero')
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0)
return (
@@ -293,6 +294,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
}): React.ReactElement {
const { t } = useTranslation()
const mobile = useIsMobile()
const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero')
const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() }
const status = getTripStatus(trip)
const start = splitDate(trip.start_date, locale)
@@ -408,7 +410,16 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
</div>
{!mobile && (
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
<div className="hero-pass-wrap">
{heroPlugins.length > 0 && (
<div className="hero-pass-overlay" aria-hidden="true">
{heroPlugins.map(p => (
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} className="hero-overlay-frame" />
))}
</div>
)}
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
</div>
)}
</div>
</section>
+4 -1
View File
@@ -12,6 +12,7 @@ export interface ActivePlugin {
name: string
type: 'integration' | 'page' | 'widget'
icon: string | null
slot?: 'sidebar' | 'hero'
}
interface PluginState {
@@ -21,6 +22,7 @@ interface PluginState {
getById: (id: string) => ActivePlugin | undefined
pages: () => ActivePlugin[]
widgets: () => ActivePlugin[]
heroWidgets: () => ActivePlugin[]
}
export const usePluginStore = create<PluginState>((set, get) => ({
@@ -38,5 +40,6 @@ export const usePluginStore = create<PluginState>((set, get) => ({
getById: (id) => get().plugins.find((p) => p.id === id),
pages: () => get().plugins.filter((p) => p.type === 'page'),
widgets: () => get().plugins.filter((p) => p.type === 'widget'),
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero'),
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
}))
+13
View File
@@ -262,6 +262,19 @@
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
}
/* Hero-slot plugin overlay: a transparent strip whose bottom edge sits on the
pass's top edge, so a plugin (e.g. the mascot) appears to stand ON the bar.
pointer-events none — it must never block the pass or hero interactions. */
.trek-dash .hero-pass-wrap { position: relative; }
.trek-dash .hero-pass-overlay {
position: absolute; left: 16px; right: 16px; bottom: 100%; height: 110px;
pointer-events: none; z-index: 3; overflow: hidden;
}
.trek-dash .hero-overlay-frame {
position: absolute; inset: 0; width: 100%; height: 100% !important;
border: 0; background: transparent; color-scheme: light;
}
.trek-dash .pass-cell {
padding: 4px 18px; position: relative; display: flex; flex-direction: column;
justify-content: center; align-items: center; text-align: center; gap: 6px; flex: 1; min-width: 0;
+47
View File
@@ -0,0 +1,47 @@
# Koffi
> A tiny animated suitcase mascot that lives on your boarding pass.
![Koffi strolling along the boarding-pass bar](./docs/screenshot.png)
## What it does
Koffi is TREK's mascot — a little vintage suitcase with travel stickers, a
swinging luggage tag, and big friendly eyes. He walks along the top edge of the
dashboard's boarding-pass bar, looks around, waves at you, reads his map, naps
(only when there's plenty of time), rolls a stretch in trolley mode, and
collects passport-stamp stickers.
He also reacts to your actual trip: the closer the departure, the more excited
he gets — under 7 days he jumps for joy and his luggage tag flips into an
airport-style split-flap countdown; while the trip is running he puts on tiny
sunglasses. Vacation mode.
## Screenshots
![Koffi on the dashboard hero](./docs/screenshot.png)
## Permissions
| Permission | Why this plugin needs it |
|---|---|
| `db:read:trips` | To read the current trip's start/end dates so Koffi knows how excited to be. The read is membership-checked by TREK, so he only ever sees a trip you already have access to. |
No network access, no data storage of its own — Koffi just vibes.
## Setup
None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install
and activate Koffi, and he appears on the boarding-pass bar of your dashboard
hero. He respects `prefers-reduced-motion` (static pose, no locomotion).
Requires TREK ≥ 3.2.0 (hero widget slot).
## Building
Plain JS/HTML — no build step. `server/index.js` is the plugin entry;
`client/index.html` is the mascot iframe.
## License
MIT — see the TREK repository.
+956
View File
@@ -0,0 +1,956 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Koffi</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow: hidden;
background: transparent;
}
/* ---- moving root: JS drives translate3d(x) ---- */
#mascot {
position: absolute;
left: 0;
bottom: 0;
width: 72px;
height: 79px;
will-change: transform;
perspective: 340px; /* gives the rotateY spin a little depth */
pointer-events: none;
--step: 0.72s; /* walk stride duration, set per mood by JS */
}
#jumper, #flip { position: absolute; inset: 0; }
#jumper { transform-origin: 50% 100%; }
#flip { transition: transform .26s ease; transform-origin: 50% 50%; }
#koffi {
display: block;
position: absolute;
left: 0;
bottom: 0;
overflow: visible;
/* rim: dark shadow + faint light halo so Koffi reads on any hero image */
filter: drop-shadow(0 1px 1px rgba(2,6,23,.35)) drop-shadow(0 0 1px rgba(248,250,252,.3));
}
/* ---- ground shadow ---- */
#shadow {
position: absolute;
left: 50%;
bottom: 0;
margin-left: -33px;
opacity: .17;
filter: blur(1.1px);
transform-origin: 50% 100%;
transition: transform .4s ease, opacity .4s ease;
}
.pose-sit #shadow { transform: scaleX(1.12); opacity: .21; }
.roll-out #shadow { transform: scaleX(1.3); opacity: .2; }
/* ---- SVG group plumbing: CSS transforms in viewBox units ---- */
#root, #trunk, #breathe, #face, #legL, #legR, #lid, #tag, #pupils, #lids,
#armF, #armB, #map, #postcard, #tele, #tagCard, #stamp, #stampAnim,
#puffS, #squint { transform-box: view-box; }
#root { transform-origin: 32px 70px; transition: transform .55s cubic-bezier(.4,0,.3,1); }
#trunk { transform-origin: 32px 60px; transition: transform .48s cubic-bezier(.34,1.4,.64,1); }
#breathe{ transform-origin: 32px 58px; animation: kfBreathe 3.4s ease-in-out infinite; }
#legL { transform-origin: 25px 53px; transition: transform .45s cubic-bezier(.34,1.4,.64,1); }
#legR { transform-origin: 39px 53px; transition: transform .45s cubic-bezier(.34,1.4,.64,1); }
#lid { transform-origin: 9px 29px; transition: transform .35s cubic-bezier(.34,1.5,.64,1); }
#tag { transform-origin: 37.5px 16px; animation: kfTagIdle 3.8s ease-in-out infinite; }
#armF { transform-origin: 54.3px 38px; transform: rotate(-8deg); transition: transform .3s ease; }
#armB { transform-origin: 10.5px 38px; transform: rotate(8deg); transition: transform .3s ease; }
#lids { transform-origin: 32px 35.4px; transform: scaleY(0); transition: transform .09s ease-out; }
#pupils { transition: transform .3s ease, opacity .12s ease; animation: kfDrift 9s ease-in-out infinite; }
#map { transform-origin: 36px 55px; transform: scale(.2,.06); opacity: 0;
transition: transform .55s cubic-bezier(.3,1.4,.4,1), opacity .2s ease; }
#postcard { transition: transform .5s cubic-bezier(.25,1.35,.4,1) .12s; }
#shades { transform: translateY(-15px); opacity: 0;
transition: transform .65s cubic-bezier(.34,1.56,.64,1) .1s, opacity .3s ease .1s; }
#flag { opacity: 0; transition: opacity .25s ease .25s; }
/* trolley kit: telescope handle + pop-out wheels, tucked by default */
#tele { transform-origin: 13px 20.5px; transform: scaleY(0);
transition: transform .16s cubic-bezier(.55,-.35,.65,1.2); }
.wheel { transform: scale(0); transition: transform .18s cubic-bezier(.55,-.35,.65,1.2); }
/* split-flap tag card + unmirrored number */
#tagCard { transform-origin: 40.6px 26.3px; }
#tagNum { opacity: 0; transform-box: fill-box; transform-origin: center; transition: opacity .18s ease; }
#tagLines{ transition: opacity .18s ease; }
.flipped #tagNum { transform: scaleX(-1); } /* counter-flip: glyphs never mirror */
.tag-num #tagNum { opacity: 1; }
.tag-num #tagLines{ opacity: 0; }
/* stamp rig sits above Koffi, slides along --sx/--sy to the target sticker slot */
#stampAnim { opacity: 0; transform-origin: 32px 45.5px; }
#stamp, #puffS { transform: translate(var(--sx, 0px), var(--sy, 0px)); }
/* stamped stickers: hidden until earned, then persist for the session */
.stk { opacity: 0; transform: scale(.2);
transition: transform .5s cubic-bezier(.3,1.5,.5,1), opacity .25s ease; }
.stk-on-1 #stk1, .stk-on-2 #stk2, .stk-on-3 #stk3, .stk-on-4 #stk4 { opacity: .95; transform: scale(1); }
.stk-b1 #stk1, .stk-b2 #stk2, .stk-b3 #stk3, .stk-b4 #stk4 { animation: kfStkPop .6s ease-in-out; }
#squint { opacity: 0; transition: opacity .12s ease; }
.conf, .streak, .pg, .ps { opacity: 0; }
.conf, .stk, .wheel, .wspin, .pg, .ps { transform-box: fill-box; transform-origin: center; }
.streak { transform-box: view-box; }
/* ---- eyes ---- */
.blink #lids, .st-sleep #lids, .eyes-closed #lids { transform: scaleY(1); }
.look-down #pupils { animation: none; transform: translate(.9px, 2.1px); }
.look-up #pupils { animation: none; transform: translate(.6px, -2.2px); }
.look-l #pupils { animation: none; transform: translate(-1.5px, .2px); }
.look-r #pupils { animation: none; transform: translate(1.5px, .2px); }
.squint #pupils { opacity: 0; }
.squint #squint { opacity: 1; }
/* ---- tag twitch (idle micro-motion) ---- */
.twitch #tag { animation: kfTagTwitch .55s ease-in-out; }
/* ---- walk cycle ---- */
.st-walk #legL { animation: kfStep var(--step) ease-in-out infinite; }
.st-walk #legR { animation: kfStep var(--step) ease-in-out infinite; animation-delay: calc(var(--step) / -2); }
.st-walk #trunk{ animation: kfBob calc(var(--step) / 2) ease-in-out infinite; }
.st-walk #face { animation: kfCounter calc(var(--step) / 2) ease-in-out infinite; animation-delay: calc(var(--step) / -10); }
.st-walk #tag { animation: kfTagWalk var(--step) ease-in-out infinite; animation-delay: calc(var(--step) / -7); }
.st-walk #lid { animation: kfLidJig calc(var(--step) / 2) ease-in-out infinite; animation-delay: calc(var(--step) / -14); }
.st-walk #armF { animation: kfArmF var(--step) ease-in-out infinite; }
.st-walk #armB { animation: kfArmB var(--step) ease-in-out infinite; }
/* tag flutter on turn-around (after walk rules so it wins for its .6s) */
.flutter #tag { animation: kfTagFlutter .6s ease-in-out; }
/* ---- sit / sleep pose ---- */
.pose-sit #trunk { transform: translateY(11px); }
.pose-sit #legL { transform: translate(18px, 11px) rotate(86deg); }
.pose-sit #legR { transform: translate(15px, 11px) rotate(74deg); }
.st-sleep #trunk { transform: translateY(11px) rotate(-4deg); }
.st-sleep #breathe { animation-name: kfBreatheDeep; animation-duration: 4.6s; }
/* ---- sleep z's (outside the flip wrapper: glyphs never mirror) ---- */
#fx { position: absolute; inset: 0; pointer-events: none; }
.zz {
position: absolute; top: 6px; opacity: 0;
font: italic 700 13px/1 "Trebuchet MS", "Segoe UI", sans-serif;
color: #EEF2FF;
text-shadow: 0 1px 2px rgba(30,27,75,.75);
}
.z1 { left: 38px; } .z2 { left: 29px; } .z3 { left: 46px; }
.st-sleep .z1 { animation: kfZ 2.7s ease-in-out infinite; }
.st-sleep .z2 { animation: kfZ 2.7s ease-in-out .9s infinite; }
.st-sleep .z3 { animation: kfZ 2.7s ease-in-out 1.8s infinite; }
/* ---- state one-shots ---- */
.st-wave #armF { animation: kfWave 1.9s ease-in-out; }
.mood-hype.st-wave #flag { opacity: 1; }
.st-jump #jumper { animation: kfJump .95s cubic-bezier(.4,0,.4,1); }
.st-jump #shadow { animation: kfShadowJump .95s cubic-bezier(.4,0,.4,1); }
.lid-crack #lid { transform: rotate(-8deg); }
.st-spin #jumper { animation: kfSpin .9s cubic-bezier(.45,.05,.55,.95); }
.st-spin #shadow { animation: kfShadowSpin .9s ease-in-out; }
.hop #jumper { animation: kfHop .45s ease-in-out; }
.hop #shadow { animation: kfShadowHop .45s ease-in-out; }
.st-peek #root { transform: rotate(9deg) translateX(1.5px); }
.st-stretch #root { animation: kfStretch 1.7s ease-in-out; }
.st-stretch #armF { animation: kfArmStretchF 1.7s ease-in-out; }
.st-stretch #armB { animation: kfArmStretchB 1.7s ease-in-out; }
.map-open #map { transform: scale(1); opacity: 1; }
.lid-open #lid { transform: rotate(-17deg); }
.lid-open #postcard { transform: translateY(-13px); }
.shades-on #shades { transform: translateY(0); opacity: 1; }
.burst .conf { animation: kfConf .8s ease-out forwards; }
.burst .conf:nth-child(2n) { animation-duration: .95s; }
/* ---- trolley roll ---- */
.roll-out #root { transform: rotate(-17deg); }
.roll-out #legL { transform: translate(2px, -9px) rotate(55deg); }
.roll-out #legR { transform: translate(0, -4px) rotate(46deg); }
.roll-out #tele { transform: scaleY(1); transition: transform .38s cubic-bezier(.34,1.45,.64,1) .06s; }
.roll-out .wheel { transform: scale(1); transition: transform .3s cubic-bezier(.34,1.6,.64,1); }
.rolling .wspin { animation: kfWheelSpin .45s linear infinite; }
/* ---- passport stamp ---- */
.stamping #stampAnim { animation: kfStamp 2.5s ease-in-out; }
.stamp-hit #trunk { animation: kfStampHit .5s ease-out; }
/* ---- split-flap tag ---- */
.flapping #tagCard { animation: kfFlap .82s ease-in-out; }
/* ---- specular shine sweeps ---- */
.sh1 #shineStk { animation: kfShine .95s ease-in-out; }
.sh2 #shineBkL, .sh3 #shineBkR { animation: kfShine .8s ease-in-out; }
/* ---- dust puffs ---- */
.puffG .pg { animation: kfPuff .6s ease-out; }
.puffS .ps { animation: kfPuff .55s ease-out; }
/* ---- keyframes ---- */
@keyframes kfStep { 0%,100% { transform: rotate(20deg); } 50% { transform: rotate(-20deg); } }
@keyframes kfBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.7px); } }
@keyframes kfCounter { 0%,100% { transform: translateY(0); } 50% { transform: translateY(1px); } }
@keyframes kfTagWalk { 0%,100% { transform: rotate(11deg); } 50% { transform: rotate(-13deg); } }
@keyframes kfTagIdle { 0%,100% { transform: rotate(-2.5deg); } 50% { transform: rotate(3deg); } }
@keyframes kfTagTwitch { 0%,100% { transform: rotate(0); } 25% { transform: rotate(10deg); } 55% { transform: rotate(-7deg); } 80% { transform: rotate(4deg); } }
@keyframes kfTagFlutter { 0%,100% { transform: rotate(0); } 20% { transform: rotate(14deg); } 45% { transform: rotate(-11deg); } 70% { transform: rotate(7deg); } 88% { transform: rotate(-3deg); } }
@keyframes kfLidJig { 0%,100% { transform: rotate(0); } 50% { transform: rotate(-1.2deg); } }
@keyframes kfArmF { 0%,100% { transform: rotate(-16deg); } 50% { transform: rotate(4deg); } }
@keyframes kfArmB { 0%,100% { transform: rotate(14deg); } 50% { transform: rotate(-4deg); } }
@keyframes kfBreathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.008, 1.022); } }
@keyframes kfBreatheDeep { 0%,100% { transform: scale(1); } 50% { transform: scale(1.015, 1.04); } }
@keyframes kfDrift {
0%,100% { transform: translate(0,0); }
22% { transform: translate(.8px,.3px); }
48% { transform: translate(-.7px,.5px); }
72% { transform: translate(.3px,-.3px); }
}
@keyframes kfZ {
0% { opacity: 0; transform: translate(0, 6px) scale(.6); }
25% { opacity: .9; }
100% { opacity: 0; transform: translate(11px, -26px) scale(1.35); }
}
@keyframes kfJump {
0% { transform: translateY(0) scale(1); }
16% { transform: translateY(0) scale(1.1, .84); } /* anticipation squash */
28% { transform: translateY(-10px) scale(.95, 1.08); } /* launch stretch */
50% { transform: translateY(-30px) scale(1, 1.02); }
72% { transform: translateY(-2px) scale(.98, 1.02); }
82% { transform: translateY(0) scale(1.12, .84); } /* landing squash */
92% { transform: translateY(0) scale(.97, 1.03); }
100% { transform: translateY(0) scale(1); }
}
@keyframes kfShadowJump {
0% { transform: scale(1); opacity: .17; }
16% { transform: scale(1.08, 1); opacity: .2; }
50% { transform: scale(.55); opacity: .06; }
82% { transform: scale(1.14, 1); opacity: .22; }
100% { transform: scale(1); opacity: .17; }
}
@keyframes kfSpin {
0% { transform: rotateY(0) translateY(0); }
45% { transform: rotateY(180deg) translateY(-8px); }
100% { transform: rotateY(360deg) translateY(0); }
}
@keyframes kfShadowSpin { 0%,100% { transform: scale(1); opacity: .17; } 45% { transform: scale(.8); opacity: .11; } }
@keyframes kfHop {
0% { transform: translateY(0) scale(1); }
25% { transform: translateY(0) scale(1.06, .9); }
55% { transform: translateY(-8px) scale(.98, 1.04); }
85% { transform: translateY(0) scale(1.05, .94); }
100% { transform: translateY(0) scale(1); }
}
@keyframes kfShadowHop { 0%,100% { transform: scale(1); opacity: .17; } 55% { transform: scale(.85); opacity: .12; } }
@keyframes kfWave {
0% { transform: rotate(-8deg); }
18% { transform: rotate(-152deg); }
32% { transform: rotate(-132deg); }
46% { transform: rotate(-158deg); }
60% { transform: rotate(-132deg); }
74% { transform: rotate(-155deg); }
100% { transform: rotate(-8deg); }
}
@keyframes kfStretch {
0% { transform: scale(1); }
30% { transform: scale(.965, 1.06); }
62% { transform: scale(.965, 1.065); }
82% { transform: scale(1.035, .955); }
100% { transform: scale(1); }
}
@keyframes kfArmStretchF { 0%,100% { transform: rotate(-8deg); } 30%,65% { transform: rotate(-168deg); } }
@keyframes kfArmStretchB { 0%,100% { transform: rotate(8deg); } 30%,65% { transform: rotate(168deg); } }
@keyframes kfConf {
0% { opacity: 0; transform: translate(0,0) scale(.4) rotate(0); }
12% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--cx), var(--cy)) scale(1.1) rotate(var(--cr)); }
}
@keyframes kfWheelSpin { to { transform: rotate(360deg); } }
@keyframes kfStamp {
0% { transform: translate(0,-44px) scale(1); opacity: 0; }
9% { transform: translate(0,-24px) scale(1); opacity: 1; }
26% { transform: translate(0,-28px) scale(1); } /* anticipation rise */
36% { transform: translate(0,1px) scale(1.05,.88); } /* slam + squash */
44% { transform: translate(0,0) scale(1); }
58% { transform: translate(0,0) scale(1); } /* press-and-hold */
68% { transform: translate(0,-10px) scale(1); }
82% { transform: translate(0,-42px) scale(1); opacity: .9; }
100% { transform: translate(0,-46px) scale(1); opacity: 0; }
}
@keyframes kfStampHit { 0% { transform: scale(1); } 30% { transform: scale(1.08,.9); } 60% { transform: scale(.97,1.05); } 100% { transform: scale(1); } }
@keyframes kfStkPop { 0%,100% { transform: scale(1); } 35% { transform: scale(1.4); } 65% { transform: scale(.88); } }
@keyframes kfFlap {
0%,25%,50%,75%,100% { transform: scaleY(1); }
12.5%,37.5%,62.5%,87.5% { transform: scaleY(.08); }
}
@keyframes kfShine {
0% { opacity: 0; transform: translateX(-7px); }
20% { opacity: .85; }
100% { opacity: 0; transform: translateX(11px); }
}
@keyframes kfPuff {
0% { opacity: 0; transform: translate(0,0) scale(.4); }
18% { opacity: .55; }
100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(1.25); }
}
/* ---- reduced motion: single static sitting pose ---- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<div id="mascot" class="mood-chill">
<svg id="shadow" width="66" height="10" viewBox="0 0 66 10" aria-hidden="true">
<ellipse cx="33" cy="5" rx="29" ry="4" fill="#000"/>
</svg>
<div id="jumper">
<div id="flip">
<svg id="koffi" width="72" height="78.75" viewBox="0 0 64 70" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<title>Koffi the travel suitcase</title>
<defs>
<clipPath id="cpTop"><rect x="0" y="0" width="64" height="30"/></clipPath>
<clipPath id="cpStk"><rect x="25" y="20.5" width="8.5" height="5.5" rx="1.4" transform="rotate(-7 29 23)"/></clipPath>
<clipPath id="cpBkL"><rect x="15.9" y="33" width="6.2" height="3.4" rx=".8"/></clipPath>
<clipPath id="cpBkR"><rect x="41.9" y="33" width="6.2" height="3.4" rx=".8"/></clipPath>
</defs>
<g id="root">
<!-- stubby legs -->
<g id="legL">
<rect x="21.5" y="52" width="7" height="14" rx="3.5" fill="#3730A3" stroke="#1E1B4B" stroke-width=".8"/>
<ellipse cx="26.5" cy="67.6" rx="5" ry="2.4" fill="#312E81" stroke="#1E1B4B" stroke-width=".8"/>
</g>
<g id="legR">
<rect x="35.5" y="52" width="7" height="14" rx="3.5" fill="#3730A3" stroke="#1E1B4B" stroke-width=".8"/>
<ellipse cx="40.5" cy="67.6" rx="5" ry="2.4" fill="#312E81" stroke="#1E1B4B" stroke-width=".8"/>
</g>
<!-- trolley wheels: pop out for the roll, tucked otherwise -->
<g id="wheels">
<g class="wheel"><g class="wspin">
<circle cx="17.5" cy="62.5" r="3" fill="#1E1B4B" stroke="#0F172A" stroke-width=".6"/>
<line x1="17.5" y1="60.2" x2="17.5" y2="64.8" stroke="#475569" stroke-width=".7"/>
<line x1="15.2" y1="62.5" x2="19.8" y2="62.5" stroke="#475569" stroke-width=".7"/>
<circle cx="17.5" cy="62.5" r="1" fill="#64748B"/>
</g></g>
<g class="wheel"><g class="wspin">
<circle cx="46.5" cy="62.5" r="3" fill="#1E1B4B" stroke="#0F172A" stroke-width=".6"/>
<line x1="46.5" y1="60.2" x2="46.5" y2="64.8" stroke="#475569" stroke-width=".7"/>
<line x1="44.2" y1="62.5" x2="48.8" y2="62.5" stroke="#475569" stroke-width=".7"/>
<circle cx="46.5" cy="62.5" r="1" fill="#64748B"/>
</g></g>
</g>
<g id="trunk">
<g id="breathe">
<!-- telescope handle (behind everything, extends up-and-back) -->
<g id="tele">
<rect x="10.6" y="4.8" width="1.7" height="16" fill="#334155" stroke="#1E1B4B" stroke-width=".6"/>
<rect x="14.2" y="4.8" width="1.7" height="16" fill="#334155" stroke="#1E1B4B" stroke-width=".6"/>
<rect x="9.4" y="3.2" width="7.6" height="3" rx="1.5" fill="#4338CA" stroke="#1E1B4B" stroke-width=".8"/>
</g>
<!-- back arm (painted behind the case) -->
<g id="armB">
<rect x="8" y="36" width="5" height="12" rx="2.5" fill="#4338CA" stroke="#312E81" stroke-width="1"/>
</g>
<!-- case lower body -->
<path d="M8,30 L56,30 L56,50 Q56,58 48,58 L16,58 Q8,58 8,50 Z"
fill="#4F46E5" stroke="#312E81" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="11.5" y="33" width="41" height="21.5" rx="5" fill="#6366F1" opacity=".28"/>
<!-- leather straps + buckles (body part) -->
<rect x="16.5" y="29.5" width="5" height="28.3" fill="#B45309" stroke="#7C2D12" stroke-width=".8"/>
<rect x="42.5" y="29.5" width="5" height="28.3" fill="#B45309" stroke="#7C2D12" stroke-width=".8"/>
<rect x="15.9" y="33" width="6.2" height="3.4" rx=".8" fill="#FCD34D" stroke="#B45309" stroke-width=".8"/>
<rect x="41.9" y="33" width="6.2" height="3.4" rx=".8" fill="#FCD34D" stroke="#B45309" stroke-width=".8"/>
<!-- buckle shine sweeps -->
<g clip-path="url(#cpBkL)"><g id="shineBkL" class="streak">
<rect x="17.8" y="30.5" width="1.6" height="8" fill="#FFFFFF" transform="rotate(24 18.6 34.5)"/>
</g></g>
<g clip-path="url(#cpBkR)"><g id="shineBkR" class="streak">
<rect x="43.8" y="30.5" width="1.6" height="8" fill="#FFFFFF" transform="rotate(24 44.6 34.5)"/>
</g></g>
<!-- pastel travel stickers -->
<circle cx="12.8" cy="45" r="3.4" fill="#F9A8D4" stroke="#DB2777" stroke-width=".6" opacity=".95"/>
<circle cx="51" cy="50.5" r="3.2" fill="#FCD34D" stroke="#D97706" stroke-width=".6" opacity=".95"/>
<rect x="31" y="51" width="6.4" height="4.6" rx="1" fill="#FDA4AF" stroke="#E11D48" stroke-width=".6"
opacity=".95" transform="rotate(5 34 53)"/>
<!-- stamped stickers: earned via passportStamp, persist for the session -->
<g id="stk1" class="stk"><g transform="rotate(-8 26.5 51.2)">
<circle cx="26.5" cy="51.2" r="2.7" fill="#93C5FD" stroke="#2563EB" stroke-width=".6"/>
<path d="M24.9,51.9 L28.2,50.4 M26.4,50.2 L27,52.4" fill="none" stroke="#1D4ED8" stroke-width=".7" stroke-linecap="round"/>
</g></g>
<g id="stk2" class="stk"><g transform="rotate(7 44.9 43.5)">
<rect x="42.4" y="41.2" width="5" height="4.6" rx="1" fill="#6EE7B7" stroke="#059669" stroke-width=".6"/>
<path d="M43.4,44.9 L44.5,42.6 L45.4,44.1 L46.1,43.2" fill="none" stroke="#047857" stroke-width=".7" stroke-linecap="round" stroke-linejoin="round"/>
</g></g>
<g id="stk3" class="stk"><g transform="rotate(-10 13.2 52.5)">
<path d="M13.2,49.9 L14,51.6 L15.8,51.8 L14.5,53 L14.8,54.8 L13.2,53.9 L11.6,54.8 L11.9,53 L10.6,51.8 L12.4,51.6 Z"
fill="#C4B5FD" stroke="#7C3AED" stroke-width=".5" stroke-linejoin="round"/>
</g></g>
<g id="stk4" class="stk"><g transform="rotate(9 48.6 42.3)">
<circle cx="48.6" cy="42.3" r="2.3" fill="#FDBA74" stroke="#EA580C" stroke-width=".6"/>
<circle cx="48.6" cy="42.3" r="1" fill="#FED7AA"/>
<path d="M48.6,39.3 L48.6,38.6 M48.6,45.3 L48.6,46 M45.6,42.3 L44.9,42.3 M51.6,42.3 L52.3,42.3"
stroke="#EA580C" stroke-width=".6" stroke-linecap="round"/>
</g></g>
<!-- postcard hiding inside the case (clipped above the lid seam) -->
<g clip-path="url(#cpTop)">
<g id="postcard">
<g transform="rotate(-6 46 39)">
<rect x="41" y="32" width="10.5" height="14" rx="1" fill="#FFF7ED" stroke="#C2410C" stroke-width="1"/>
<rect x="41.6" y="32.6" width="9.3" height="2.6" fill="#FDA4AF"/>
<rect x="47.8" y="36.4" width="2.6" height="2.6" fill="#93C5FD" stroke="#3B82F6" stroke-width=".5"/>
<line x1="42.4" y1="41" x2="47" y2="41" stroke="#FDBA74" stroke-width=".9" stroke-linecap="round"/>
<line x1="42.4" y1="43" x2="45.6" y2="43" stroke="#FDBA74" stroke-width=".9" stroke-linecap="round"/>
</g>
</g>
</g>
<!-- lid (hinged at the back top corner) -->
<g id="lid">
<path d="M8,30 L8,26 A8,8 0 0 1 16,18 L48,18 A8,8 0 0 1 56,26 L56,30 Z"
fill="#4F46E5" stroke="#312E81" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="16.5" y="17.8" width="5" height="12.4" fill="#B45309" stroke="#7C2D12" stroke-width=".8"/>
<rect x="42.5" y="17.8" width="5" height="12.4" fill="#B45309" stroke="#7C2D12" stroke-width=".8"/>
<rect x="25" y="20.5" width="8.5" height="5.5" rx="1.4" fill="#6EE7B7" stroke="#059669" stroke-width=".6"
opacity=".95" transform="rotate(-7 29 23)"/>
<!-- sticker shine sweep (clipped to the mint sticker, rides the lid) -->
<g clip-path="url(#cpStk)"><g id="shineStk" class="streak">
<rect x="26.5" y="17.5" width="2" height="10" fill="#FFFFFF" transform="rotate(24 27.5 22.5)"/>
</g></g>
<path d="M26,17.5 L26,15 A3.5,3.5 0 0 1 29.5,11.5 L34.5,11.5 A3.5,3.5 0 0 1 38,15 L38,17.5"
fill="none" stroke="#92400E" stroke-width="2.6" stroke-linecap="round"/>
<!-- luggage tag: swings from the handle; inner card does the split-flap -->
<g id="tag">
<path d="M37.5,16 Q39.5,19 39,21.5" fill="none" stroke="#92400E" stroke-width=".9"/>
<g id="tagCard">
<g transform="rotate(-10 40 22)">
<rect x="36.8" y="21.5" width="7.6" height="9.6" rx="1.4" fill="#FEF3C7" stroke="#B45309" stroke-width="1"/>
<circle cx="40.6" cy="23.6" r=".9" fill="none" stroke="#B45309" stroke-width=".8"/>
<g id="tagLines">
<line x1="38.3" y1="26.6" x2="43" y2="26.6" stroke="#D97706" stroke-width=".9" stroke-linecap="round"/>
<line x1="38.3" y1="28.5" x2="41.8" y2="28.5" stroke="#D97706" stroke-width=".9" stroke-linecap="round"/>
</g>
<text id="tagNum" x="40.6" y="29.6" text-anchor="middle"
font-family="'Trebuchet MS','Segoe UI',sans-serif" font-size="6.5" font-weight="700"
fill="#B45309">7</text>
</g>
</g>
</g>
</g>
<!-- face -->
<g id="face">
<ellipse cx="26.5" cy="40" rx="4.2" ry="4.7" fill="#FFFFFF" stroke="#312E81" stroke-width="1"/>
<ellipse cx="37.5" cy="40" rx="4.2" ry="4.7" fill="#FFFFFF" stroke="#312E81" stroke-width="1"/>
<g id="pupils">
<circle cx="27.3" cy="40.6" r="2" fill="#1E1B4B"/>
<circle cx="38.3" cy="40.6" r="2" fill="#1E1B4B"/>
<circle cx="28" cy="39.7" r=".7" fill="#FFFFFF"/>
<circle cx="39" cy="39.7" r=".7" fill="#FFFFFF"/>
</g>
<g id="lids">
<rect x="22.1" y="35" width="8.8" height="10.4" rx="4.4" fill="#4F46E5" stroke="#312E81" stroke-width=".6"/>
<rect x="33.1" y="35" width="8.8" height="10.4" rx="4.4" fill="#4F46E5" stroke="#312E81" stroke-width=".6"/>
</g>
<!-- happy squint ^ ^ overlay -->
<g id="squint">
<path d="M23.2,41.2 Q26.5,37.8 29.8,41.2" fill="none" stroke="#1E1B4B" stroke-width="1.5" stroke-linecap="round"/>
<path d="M34.2,41.2 Q37.5,37.8 40.8,41.2" fill="none" stroke="#1E1B4B" stroke-width="1.5" stroke-linecap="round"/>
</g>
<path d="M29.5,46.5 Q32,48.8 34.5,46.5" fill="none" stroke="#1E1B4B" stroke-width="1.3" stroke-linecap="round"/>
<ellipse cx="24" cy="44.6" rx="1.9" ry="1.2" fill="#F9A8D4" opacity=".5"/>
<ellipse cx="40" cy="44.6" rx="1.9" ry="1.2" fill="#F9A8D4" opacity=".5"/>
<!-- sunglasses: slide down once in live mood -->
<g id="shades">
<line x1="21.6" y1="38.2" x2="9.5" y2="37" stroke="#0F172A" stroke-width="1.2"/>
<line x1="42.4" y1="38.2" x2="55" y2="37.2" stroke="#0F172A" stroke-width="1.2"/>
<line x1="31" y1="38.6" x2="33" y2="38.6" stroke="#0F172A" stroke-width="1.4"/>
<rect x="21.6" y="36" width="9.8" height="6.8" rx="2.8" fill="#0F172A" stroke="#334155" stroke-width=".8"/>
<rect x="32.6" y="36" width="9.8" height="6.8" rx="2.8" fill="#0F172A" stroke="#334155" stroke-width=".8"/>
<line x1="24" y1="38" x2="26.5" y2="40.5" stroke="#F8FAFC" stroke-width=".8" opacity=".45" stroke-linecap="round"/>
</g>
</g>
<!-- front arm + pennant (pennant only shows on hype waves) -->
<g id="armF">
<rect x="51.8" y="36.5" width="5" height="12" rx="2.5" fill="#4F46E5" stroke="#312E81" stroke-width="1"/>
<g id="flag">
<rect x="53.6" y="47" width="1.4" height="10" fill="#92400E"/>
<polygon points="55,49 63.5,51.5 55,54" fill="#F472B6" stroke="#DB2777" stroke-width=".6"/>
</g>
</g>
<!-- tiny fold-out map -->
<g id="map">
<g transform="rotate(-5 36 49.5)">
<rect x="27" y="44" width="18" height="11.5" rx="1" fill="#ECFDF5" stroke="#047857" stroke-width="1"/>
<line x1="33" y1="44" x2="33" y2="55.5" stroke="#A7F3D0" stroke-width="1"/>
<line x1="39" y1="44" x2="39" y2="55.5" stroke="#A7F3D0" stroke-width="1"/>
<path d="M29.5,52 Q33,46.5 36.5,50 T43,47.5" fill="none" stroke="#EF4444" stroke-width="1"
stroke-dasharray="2 1.4" stroke-linecap="round"/>
<circle cx="43" cy="47.5" r="1.1" fill="#EF4444"/>
</g>
</g>
<!-- confetti puff (hype landings + lid pop) -->
<g id="confetti">
<circle class="conf" cx="32" cy="14" r="1.6" fill="#F9A8D4" style="--cx:-14px;--cy:-20px;--cr:120deg"/>
<rect class="conf" x="30" y="12" width="3" height="3" fill="#6EE7B7" style="--cx:10px;--cy:-24px;--cr:200deg"/>
<circle class="conf" cx="33" cy="13" r="1.3" fill="#FCD34D" style="--cx:18px;--cy:-14px;--cr:90deg"/>
<rect class="conf" x="31" y="13" width="2.6" height="2.6" fill="#A5B4FC" style="--cx:-18px;--cy:-10px;--cr:-160deg"/>
<circle class="conf" cx="31" cy="12" r="1.2" fill="#FDA4AF" style="--cx:-6px;--cy:-24px;--cr:60deg"/>
<rect class="conf" x="32.5" y="12" width="2.4" height="2.4" fill="#7DD3FC" style="--cx:22px;--cy:-20px;--cr:240deg"/>
</g>
</g>
</g>
</g>
<!-- ground dust puff (feet level, outside #jumper's airborne groups) -->
<g id="puffG">
<circle class="pg" cx="26" cy="66.5" r="1.7" fill="#E2E8F0" style="--px:-9px;--py:-3px"/>
<circle class="pg" cx="38" cy="66.5" r="1.5" fill="#F1F5F9" style="--px:9px;--py:-4px"/>
<circle class="pg" cx="30" cy="67" r="1.2" fill="#CBD5E1" style="--px:-4px;--py:-7px"/>
<circle class="pg" cx="35" cy="67" r="1.3" fill="#E2E8F0" style="--px:5px;--py:-6px"/>
</g>
<!-- stamp-impact puff (follows --sx/--sy to the sticker slot) -->
<g id="puffS">
<circle class="ps" cx="30" cy="44" r="1.2" fill="#FDE68A" style="--px:-6px;--py:-5px"/>
<circle class="ps" cx="34" cy="43.5" r="1" fill="#F1F5F9" style="--px:6px;--py:-4px"/>
<path class="ps" d="M32,40.6 L32.7,42.1 L34.2,42.4 L33.1,43.4 L33.4,45 L32,44.2 L30.6,45 L30.9,43.4 L29.8,42.4 L31.3,42.1 Z"
fill="#FCD34D" style="--px:1px;--py:-8px"/>
<circle class="ps" cx="31" cy="45" r=".9" fill="#E2E8F0" style="--px:-3px;--py:-7px"/>
</g>
<!-- passport stamp rig: descends from above the strip -->
<g id="stamp"><g id="stampAnim">
<rect x="28.4" y="24" width="7.2" height="6.6" rx="3" fill="#B45309" stroke="#7C2D12" stroke-width=".9"/>
<rect x="30.4" y="29.8" width="3.2" height="7.4" fill="#D97706" stroke="#7C2D12" stroke-width=".8"/>
<rect x="29.2" y="36.6" width="5.6" height="3" fill="#4F46E5" stroke="#312E81" stroke-width=".7"/>
<path d="M25,45.5 L25,42.5 Q25,39.6 27.9,39.6 L36.1,39.6 Q39,39.6 39,42.5 L39,45.5 Z"
fill="#312E81" stroke="#1E1B4B" stroke-width=".9"/>
</g></g>
</svg>
</div>
</div>
<!-- z glyphs live outside #flip so they never get mirrored -->
<div id="fx">
<span class="zz z1">z</span>
<span class="zz z2">z</span>
<span class="zz z3">z</span>
</div>
</div>
<script>
(function () {
'use strict';
var DEBUG = false;
function log() { if (DEBUG && window.console) console.log.apply(console, ['[koffi]'].concat([].slice.call(arguments))); }
var CONFIG = {
width: 72,
edgePad: 6,
turnPad: 20, // start turning this far before an edge
peekSnap: 34, // close enough to an edge to peek right away
speed: { chill: 26, soon: 38, hype: 62, live: 33 }, // px/s
rollBoost: 1.5, // trolley glides faster than walking
step: { chill: '0.72s', soon: '0.56s', hype: '0.38s', live: '0.66s' },
pollMs: 600000,
waveEveryMs: 90000,
invokeTimeoutMs: 15000,
dur: {
walk: [2600, 7200], idle: [1500, 3400], sit: [3200, 7500], sleep: [6500, 12000],
stretch: [1750, 1750], wave: [2050, 2050], jump: [1050, 1050], spin: [980, 980],
peek: [2000, 3400], map: [3900, 3900], lid: [3000, 3000],
roll: [4600, 4600], stamp: [3550, 3550], flap: [3300, 3300]
},
weights: {
chill: { walk: 5, idle: 4, sit: 3, map: 2.2, wave: .7, jump: .5, peek: 1.2, lid: 1.2, spin: 0, roll: .3, stamp: .3, flap: 0 },
soon: { walk: 6, idle: 3, sit: 1.6, map: 1.6, wave: 1, jump: 1.7, peek: 1.2, lid: 1.4, spin: .2, roll: .45, stamp: .35, flap: 0 },
hype: { walk: 7, idle: 1.4, sit: .4, map: .7, wave: 2, jump: 3.4, peek: 1.2, lid: 1.8, spin: .9, roll: .6, stamp: .4, flap: 1.15 },
live: { walk: 5, idle: 3, sit: 2.2, map: 1, wave: 1.2, jump: 1.2, peek: 1, lid: 1.2, spin: 1.9, roll: 1.15, stamp: .45, flap: 0 }
},
sleepFromSit: { chill: 2.6, soon: 1.2, hype: 0, live: 1 }
};
// stamped-sticker slots: [--sx, --sy] offsets steering the stamp + puff to each spot
var STICKER_SLOTS = [[-5.5, 7.2], [12.9, -.5], [-18.8, 8.5], [16.6, -1.7]];
var mascot = document.getElementById('mascot');
var flip = document.getElementById('flip');
var tagNum = document.getElementById('tagNum');
var reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
var STATE_CLASSES = ['st-walk', 'st-idle', 'st-sit', 'st-sleep', 'st-stretch', 'st-wave', 'st-jump',
'st-spin', 'st-peek', 'st-map', 'st-lid', 'st-roll', 'st-stamp', 'st-flap',
'pose-sit', 'lid-open', 'lid-crack', 'map-open',
'look-down', 'look-up', 'look-l', 'look-r', 'eyes-closed', 'hop', 'burst', 'twitch',
'roll-out', 'rolling', 'stamping', 'stamp-hit', 'squint', 'flapping', 'tag-num',
'puffG', 'puffS', 'stk-b1', 'stk-b2', 'stk-b3', 'stk-b4'];
var mood = 'chill', ctx = null, seq = 0, pending = {};
var x = 0, dir = 1, vx = 0, minX = CONFIG.edgePad, maxX = 100;
var state = '', walkTarget = null, afterWalk = null, forceNext = null;
var lastWave = performance.now(), shadesOn = false, lastDays = null;
var rollMoving = false, rollTucked = true, stampCount = 0, booted = false;
var timers = [];
function rand(a, b) { return a + Math.random() * (b - a); }
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function later(fn, ms) { var t = setTimeout(fn, ms); timers.push(t); return t; }
function clearTimers() { for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]); timers.length = 0; }
function addC(c) { mascot.classList.add(c); }
function remC(c) { mascot.classList.remove(c); }
function hasC(c) { return mascot.classList.contains(c); }
function layout() {
maxX = Math.max(minX, window.innerWidth - CONFIG.width - CONFIG.edgePad);
x = clamp(x, minX, maxX);
}
window.addEventListener('resize', layout);
function setDir(d) {
if (booted && d !== dir && !reduced) flutterTag(); // tag flutters on turn-around
dir = d;
flip.style.transform = 'scaleX(' + (d < 0 ? -1 : 1) + ')';
mascot.classList.toggle('flipped', d < 0); // counter-flips tag glyphs
}
function pick(w) {
var sum = 0, k;
for (k in w) sum += w[k];
var r = Math.random() * sum;
for (k in w) { r -= w[k]; if (r <= 0) return k; }
return 'idle';
}
function chooseNext() {
var now = performance.now();
if (state === 'sleep') return 'stretch'; // always shake it off after a nap
if (state === 'stretch') return 'idle';
if (now - lastWave > CONFIG.waveEveryMs && state !== 'wave') return 'wave';
if (forceNext) { var f = forceNext; forceNext = null; return f; }
if (state === 'sit') return pick({ walk: 3, idle: 2, sleep: CONFIG.sleepFromSit[mood] || 0 });
var base = CONFIG.weights[mood], w = {}, k;
for (k in base) if (base[k] > 0) w[k] = base[k];
if (!(mood === 'hype' && lastDays != null)) delete w.flap; // split-flap needs a real countdown
if (maxX - minX < 130) delete w.roll; // trolley needs runway
if (state !== 'walk') delete w[state]; // no immediate repeats except walking
return pick(w);
}
function burst() { remC('burst'); void mascot.offsetWidth; addC('burst'); }
function twitchTag() { addC('twitch'); later(function () { remC('twitch'); }, 600); }
function flutterTag() {
remC('flutter'); void mascot.offsetWidth; addC('flutter');
setTimeout(function () { remC('flutter'); }, 640);
}
function puffGround() {
remC('puffG'); void mascot.offsetWidth; addC('puffG');
later(function () { remC('puffG'); }, 800);
}
function tuckRoll() {
if (rollTucked) return;
rollTucked = true;
rollMoving = false;
remC('rolling');
remC('roll-out');
later(puffGround, 150);
}
var ENTER = {
walk: function () { if (Math.random() < .3) setDir(-dir); },
idle: function () {
if (Math.random() < .8) later(function () {
addC(Math.random() < .5 ? 'look-l' : 'look-r');
later(function () { remC('look-l'); remC('look-r'); }, rand(500, 900));
}, rand(300, 1200));
if (Math.random() < .5) later(twitchTag, rand(600, 1800));
},
sit: function () {
addC('pose-sit');
if (Math.random() < .6) later(twitchTag, rand(1000, 3000));
},
sleep: function () { addC('pose-sit'); },
stretch: function () { addC('eyes-closed'); later(function () { remC('eyes-closed'); }, 650); },
wave: function () {
lastWave = performance.now();
later(function () { addC('squint'); }, 450);
later(function () { remC('squint'); }, 1550);
},
jump: function () {
later(function () { addC('squint'); }, 300);
later(function () { remC('squint'); }, 700);
later(puffGround, 770); // dust on landing
if (mood === 'hype') {
later(function () { addC('lid-crack'); }, 140);
later(function () { remC('lid-crack'); }, 620);
later(burst, 640); // confetti pops on the way down, never at apex
}
},
spin: function () {},
peek: function () {
setDir(x - minX < maxX - x ? -1 : 1);
later(function () { addC('look-down'); }, 380);
},
map: function () {
later(function () { addC('map-open'); }, 260);
later(function () { addC('look-down'); }, 620);
later(function () { remC('look-down'); addC('look-up'); }, 2150);
later(function () { remC('look-up'); addC('look-down'); }, 2800);
later(function () { remC('map-open'); remC('look-down'); }, 3350);
},
// signature move: the Lid Pop — postcard peeks out of the cracked lid
lid: function () {
later(function () { addC('lid-open'); addC('look-up'); if (mood === 'hype') burst(); }, 220);
later(function () { remC('lid-open'); }, 2150);
later(function () { remC('look-up'); addC('hop'); }, 2400);
},
// trolley mode: wheels pop, handle telescopes out, lean back and glide
roll: function () {
setDir(x - minX < maxX - x ? 1 : -1); // roll toward the long side
forceNext = 'walk'; // always resume walking after
rollTucked = false;
addC('roll-out');
later(function () { rollMoving = true; addC('rolling'); }, 430);
later(function () { // bump over an imaginary crack
if (rollMoving) { addC('hop'); later(function () { remC('hop'); }, 470); }
}, rand(1500, 2700));
later(tuckRoll, 3550); // snap everything away, dust settles
},
// passport control: a chunky stamp descends and awards a sticker
stamp: function () {
var n = stampCount < 4 ? stampCount + 1 : 1 + Math.floor(Math.random() * 4);
var slot = STICKER_SLOTS[n - 1];
mascot.style.setProperty('--sx', slot[0] + 'px');
mascot.style.setProperty('--sy', slot[1] + 'px');
later(function () { addC('stamping'); }, 120);
later(function () { addC('look-up'); }, 320);
later(function () { // impact frame
remC('look-up');
addC('stamp-hit');
remC('puffS'); void mascot.offsetWidth; addC('puffS');
if (stampCount < 4) { addC('stk-on-' + n); stampCount++; }
else { addC('stk-b' + n); later(function () { remC('stk-b' + n); }, 650); }
}, 1030);
later(function () { remC('stamp-hit'); remC('puffS'); }, 1700);
later(function () { addC('look-down'); }, 2000); // admire the new sticker
later(function () { addC('squint'); }, 2250);
later(function () { addC('hop'); }, 2450);
later(function () { remC('hop'); }, 2950);
later(function () { remC('squint'); remC('look-down'); }, 3150);
},
// airport split-flap: the tag flips and shows days-until-trip (hype only)
flap: function () {
var d = clamp(Math.round(lastDays == null ? 0 : lastDays), 0, 99);
later(function () { addC('look-up'); }, 200);
later(function () { addC('flapping'); }, 350);
later(function () { // swap content mid-flap
if (tagNum) tagNum.textContent = String(d);
addC('tag-num');
}, 760);
later(function () { remC('flapping'); }, 1250);
later(function () { addC('squint'); }, 1500);
later(function () { addC('hop'); }, 1600);
later(function () { remC('hop'); remC('squint'); }, 2150);
later(function () { remC('look-up'); }, 2600);
}
};
function setState(name) {
clearTimers();
rollMoving = false;
rollTucked = true;
for (var i = 0; i < STATE_CLASSES.length; i++) remC(STATE_CLASSES[i]);
// peek needs an edge: walk over first if we're mid-strip
if (name === 'peek') {
var edge = (x - minX < maxX - x) ? minX : maxX;
if (Math.abs(x - edge) > CONFIG.peekSnap) {
walkTarget = edge;
afterWalk = 'peek';
setDir(edge < x ? -1 : 1);
state = 'walk';
addC('st-walk');
later(end, Math.abs(x - edge) / CONFIG.speed[mood] * 1000 + 4000); // safety net
return;
}
}
state = name;
walkTarget = null;
addC('st-' + name);
if (ENTER[name]) ENTER[name]();
var d = CONFIG.dur[name] || [2000, 3000];
later(end, rand(d[0], d[1]));
log('state:', name, 'mood:', mood);
}
function end() {
var q = afterWalk;
afterWalk = null;
setState(q || chooseNext());
}
/* ---- locomotion ---- */
var lastT = performance.now();
var lastTurnT = -1e9;
function frame(t) {
var dt = Math.min(.05, (t - lastT) / 1000);
lastT = t;
var moving = state === 'walk' || (state === 'roll' && rollMoving);
var tv = moving ? dir * CONFIG.speed[mood] * (state === 'roll' ? CONFIG.rollBoost : 1) : 0;
vx += (tv - vx) * Math.min(1, dt * (moving ? 4.5 : 9));
x = clamp(x + vx * dt, minX, maxX);
if (state === 'walk') {
if (walkTarget == null) {
// Early-turn only when the strip is wide enough for two pads, and never
// twice within 500ms — a tiny iframe otherwise flip-thrashes every frame.
var pad = maxX - minX > CONFIG.turnPad * 2 + 8 ? CONFIG.turnPad : 0;
if (t - lastTurnT > 500) {
if (dir < 0 && x <= minX + pad) { lastTurnT = t; setDir(1); }
else if (dir > 0 && x >= maxX - pad) { lastTurnT = t; setDir(-1); }
}
} else if (Math.abs(x - walkTarget) < 3 || x <= minX || x >= maxX) {
walkTarget = null;
end();
}
} else if (state === 'roll' && rollMoving) {
if ((dir < 0 && x <= minX + 4) || (dir > 0 && x >= maxX - 4)) tuckRoll(); // never roll off the edge
}
mascot.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)';
requestAnimationFrame(frame);
}
/* ---- blinking (independent of the FSM) ---- */
function doBlink() {
mascot.classList.add('blink');
setTimeout(function () { mascot.classList.remove('blink'); }, 130);
}
(function blinkLoop() {
setTimeout(function () {
if (state !== 'sleep' && !reduced && !hasC('squint')) {
doBlink();
if (Math.random() < .3) setTimeout(doBlink, 250); // double blink
}
blinkLoop();
}, rand(2300, 5600));
})();
/* ---- specular shine sweeps (independent micro-life) ---- */
(function shineLoop() {
setTimeout(function () {
if (!reduced && state !== 'sleep') {
var c = ['sh1', 'sh2', 'sh3'][Math.floor(Math.random() * 3)];
addC(c);
setTimeout(function () { remC(c); }, 1100);
}
shineLoop();
}, rand(9000, 22000));
})();
/* ---- mood ---- */
function setMood(m) {
if (!CONFIG.weights[m]) m = 'chill';
var changed = m !== mood;
mood = m;
mascot.classList.remove('mood-chill', 'mood-soon', 'mood-hype', 'mood-live');
mascot.classList.add('mood-' + m);
mascot.style.setProperty('--step', CONFIG.step[m]);
if (m === 'live') {
if (!shadesOn) { shadesOn = true; setTimeout(function () { if (shadesOn) addC('shades-on'); }, 700); }
} else if (shadesOn) {
shadesOn = false;
remC('shades-on');
}
if (changed && m === 'hype' && !reduced && (state === 'sleep' || state === 'sit')) {
setState(state === 'sleep' ? 'stretch' : 'jump');
}
log('mood:', m);
}
/* ---- trekBridge ---- */
function post(msg) { try { if (window.parent) window.parent.postMessage(msg, '*'); } catch (e) {} }
function requestState() {
if (!ctx || ctx.tripId == null || ctx.tripId === '') return;
var id = 'koffi-' + (++seq) + '-' + Date.now().toString(36);
pending[id] = true;
setTimeout(function () { delete pending[id]; }, CONFIG.invokeTimeoutMs);
post({ type: 'trek:invoke', requestId: id, sub: '/state?tripId=' + ctx.tripId, method: 'GET' });
}
function applyTripState(d) {
if (!d || typeof d !== 'object') return;
var days = (typeof d.days === 'number' && isFinite(d.days)) ? d.days : null;
lastDays = days;
var m = 'chill';
if (d.ongoing === true) m = 'live';
else if (days !== null && days >= 0 && days <= 7) m = 'hype';
else if (days !== null && days > 7 && days <= 14) m = 'soon';
setMood(m);
}
window.addEventListener('message', function (e) {
var m = e && e.data;
if (!m || typeof m !== 'object') return;
if (m.type === 'trek:context') {
ctx = m;
requestState();
} else if (m.type === 'trek:response' && pending[m.requestId]) {
delete pending[m.requestId];
applyTripState(m.data);
} else if (m.type === 'trek:error' && pending[m.requestId]) {
delete pending[m.requestId];
log('invoke failed, keeping current mood');
}
});
setInterval(requestState, CONFIG.pollMs);
post({ type: 'trek:ready' });
/* ---- boot ---- */
layout();
x = clamp(minX + (maxX - minX) * rand(.3, .7), minX, maxX);
setDir(Math.random() < .5 ? -1 : 1);
setMood('chill');
mascot.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)';
booted = true;
if (reduced) {
addC('pose-sit'); // single static cute pose, eyes open, shadow stays
} else {
setState('idle');
requestAnimationFrame(frame);
}
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+42
View File
@@ -0,0 +1,42 @@
// Koffi — the TREK suitcase mascot (hero-slot widget).
//
// One route: GET /state?tripId= → { days, ongoing, title }. The mascot's mood is
// driven by how close the trip is. The host membership-checks the trip against
// the acting user, so Trekki can only ever see a trip that user can see.
const DAY_MS = 86400000;
function midnight(iso) {
const d = new Date(iso + 'T00:00:00');
return Number.isNaN(d.getTime()) ? null : d.getTime();
}
module.exports = {
routes: [
{
method: 'GET',
path: '/state',
auth: true,
async handler(req, ctx) {
const json = { 'content-type': 'application/json' };
const tripId = Number(req.query.tripId);
if (!tripId) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) };
const t = await ctx.trips.getById(tripId, req.user.id);
if (!t) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) };
const now = new Date();
now.setHours(0, 0, 0, 0);
const start = t.start_date ? midnight(t.start_date) : null;
const end = t.end_date ? midnight(t.end_date) : null;
const ongoing = start !== null && start <= now.getTime() && (end === null || now.getTime() <= end);
const days = !ongoing && start !== null && start >= now.getTime()
? Math.round((start - now.getTime()) / DAY_MS)
: null;
return { status: 200, headers: json, body: JSON.stringify({ days, ongoing, title: t.title ?? null }) };
},
},
],
};
@@ -0,0 +1,21 @@
{
"id": "koffi",
"name": "Koffi",
"version": "1.0.0",
"apiVersion": 1,
"author": "TREK",
"description": "Koffi, the tiny suitcase mascot, lives on your boarding pass — strolling, rolling, collecting stickers, and getting more excited the closer your trip gets.",
"homepage": "https://github.com/mauriceboe/trek-plugin-koffi",
"license": "MIT",
"icon": "Luggage",
"type": "widget",
"trek": ">=3.2.0 <4.0.0",
"nativeModules": false,
"permissions": ["db:read:trips"],
"capabilities": {
"widget": { "title": "Koffi", "slot": "hero" }
},
"routes": [
{ "method": "GET", "path": "/state", "auth": true }
]
}
@@ -1,39 +0,0 @@
# Trip Countdown
> A dashboard widget that counts down the days to your next trip.
![Trip Countdown widget on the dashboard](./docs/screenshot.png)
## What it does
Trip Countdown adds a small dashboard widget showing how many days remain until
your upcoming trip starts. When a trip is under way it shows "in progress"
instead. It's the reference plugin for the TREK plugin system — a minimal but
complete widget that reads trip data through the sandbox and renders it in an
isolated iframe.
## Screenshots
![The widget counting down to a trip](./docs/screenshot.png)
## Permissions
| Permission | Why this plugin needs it |
|---|---|
| `db:read:trips` | To read the current trip's start date so it can compute the countdown. The read is membership-checked by TREK, so the widget only ever sees a trip you already have access to. |
It requests **no** network access and stores **no** data of its own.
## Setup
None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install and
activate Trip Countdown, and the widget appears on your dashboard.
## Building
The widget ships plain JS/HTML — no build step. `server/index.js` is the plugin
entry; `client/index.html` is the widget iframe.
## License
MIT — see the TREK repository.
@@ -1,62 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Trip Countdown</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font: 14px system-ui, sans-serif; display: grid; place-items: center; height: 100vh;
background: transparent; color: #111827; }
body[data-theme="dark"] { color: #e5e7eb; }
.card { text-align: center; padding: 16px; }
.days { font-size: 40px; font-weight: 800; letter-spacing: -0.02em; }
.label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6; margin-top: 2px; }
.name { margin-top: 6px; opacity: 0.85; }
.empty { opacity: 0.6; }
</style>
</head>
<body>
<div class="card" id="root"><span class="empty">Loading…</span></div>
<script>
var ctx = null
var reqSeq = 0
function send(msg) { window.parent.postMessage(msg, '*') }
function render(data) {
var root = document.getElementById('root')
if (!data || !data.trip) { root.innerHTML = '<span class="empty">No upcoming trip</span>'; return }
var d = data.trip.days
root.innerHTML = d == null
? '<div class="name">' + escapeHtml(data.trip.name) + '</div><div class="label">in progress</div>'
: '<div class="days">' + d + '</div><div class="label">days to go</div><div class="name">' + escapeHtml(data.trip.name) + '</div>'
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] }) }
function load() {
var id = 'r' + (++reqSeq)
send({ type: 'trek:invoke', requestId: id, sub: '/next?tripId=' + (ctx && ctx.tripId ? ctx.tripId : ''), method: 'GET' })
window._pending = id
}
window.addEventListener('message', function (e) {
var m = e.data
if (!m || typeof m !== 'object') return
if (m.type === 'trek:context') {
ctx = m
document.body.dataset.theme = m.theme
load()
} else if (m.type === 'trek:response' && m.requestId === window._pending) {
render(m.data)
} else if (m.type === 'trek:error' && m.requestId === window._pending) {
document.getElementById('root').innerHTML = '<span class="empty">Unavailable</span>'
}
})
// Ask TREK for context (theme, current trip id) — it replies with trek:context.
send({ type: 'trek:ready' })
</script>
</body>
</html>
@@ -1,43 +0,0 @@
// Trip Countdown — reference widget plugin.
//
// Demonstrates the full author path: definePlugin, a permission-gated route that
// reads trip data through ctx, and a response the client iframe renders. Runs
// isolated; ctx is the only way to reach TREK.
const { definePlugin } = require('trek-plugin-sdk')
/** Days (integer, >= 0) between now and an ISO date, or null if past/absent. */
function daysUntil(iso) {
if (!iso) return null
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return null
const diff = Math.ceil((then - Date.now()) / 86_400_000)
return diff >= 0 ? diff : null
}
module.exports = definePlugin({
routes: [
{
method: 'GET',
path: '/next',
auth: true,
async handler(req, ctx) {
// The client passes the dashboard's current trip id; the host enforces that
// req.user actually has access to it, so a plugin can't read foreign trips.
const tripId = Number(req.query.tripId)
if (!tripId) return { status: 200, headers: json(), body: JSON.stringify({ trip: null }) }
const t = await ctx.trips.getById(tripId, req.user.id)
const days = daysUntil(t && (t.start_date || t.startDate))
return {
status: 200,
headers: json(),
body: JSON.stringify({ trip: t ? { name: t.title, days } : null }),
}
},
},
],
})
function json() {
return { 'content-type': 'application/json' }
}
@@ -1,21 +0,0 @@
{
"id": "trip-countdown",
"name": "Trip Countdown",
"version": "1.0.0",
"apiVersion": 1,
"author": "TREK",
"description": "A dashboard widget counting down the days to your next trip.",
"homepage": "https://github.com/mauriceboe/TREK",
"license": "MIT",
"icon": "CalendarClock",
"type": "widget",
"trek": ">=3.2.0 <4.0.0",
"nativeModules": false,
"permissions": ["db:read:trips"],
"capabilities": {
"widget": { "title": "Trip Countdown", "defaultSize": "small" }
},
"routes": [
{ "method": "GET", "path": "/next", "auth": true }
]
}
+5
View File
@@ -56,6 +56,11 @@ export function validateManifest(raw: unknown): ValidationResult {
if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty');
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget;
if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') {
errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`);
}
if (errors.length) return { ok: false, errors };
return {
ok: true,
+7 -4
View File
@@ -86,8 +86,8 @@ describe('scaffold + validate CLIs', () => {
});
});
describe('reference plugin (examples/trip-countdown)', () => {
const dir = path.resolve(import.meta.dirname, '..', 'examples', 'trip-countdown');
describe('reference plugin (examples/koffi)', () => {
const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
it('passes the same validation authors run', () => {
const r = validatePluginDir(dir);
@@ -95,10 +95,13 @@ describe('reference plugin (examples/trip-countdown)', () => {
expect(r.ok).toBe(true);
});
it('has a valid, minimal-permission manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8'));
it('has a valid, minimal-permission hero-widget manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as {
capabilities?: { widget?: { slot?: string } };
};
const res = validateManifest(manifest);
expect(res.ok).toBe(true);
expect(res.manifest?.permissions).toEqual(['db:read:trips']);
expect(manifest.capabilities?.widget?.slot).toBe('hero');
});
});
+12 -4
View File
@@ -3357,10 +3357,18 @@ function runMigrations(db: Database.Database): void {
// erased the "keep it on" intent, so the plugin never rebooted after a deploy.
// Boot now retries every enabled plugin regardless of last status.
() => {
db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
// Anything not explicitly deactivated was meant to be on ('inactive' is the
// only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
try {
db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
// Anything not explicitly deactivated was meant to be on ('inactive' is the
// only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 157: plugin capabilities (from trek-plugin.json) — the client
// needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay
// on the boarding-pass bar instead of the dashboard sidebar).
() => {
try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
];
+5 -5
View File
@@ -45,13 +45,13 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), m.id);
min_trek_version = ?, permissions = ?, capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), m.id);
} else {
db.prepare(
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions));
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities));
}
// Refresh the settings-field descriptors from the manifest.
@@ -20,6 +20,17 @@ export interface ManifestSettingField {
oauth?: { initPath?: string; callbackPath?: string };
}
export interface WidgetCapability {
title?: string;
defaultSize?: string;
/** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */
slot?: 'sidebar' | 'hero';
}
export interface PluginCapabilities {
widget?: WidgetCapability;
}
export interface PluginManifest {
id: string;
name: string;
@@ -36,6 +47,7 @@ export interface PluginManifest {
permissions: string[];
egress: string[];
settings: ManifestSettingField[];
capabilities: PluginCapabilities;
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
@@ -84,9 +96,27 @@ export function parseManifest(raw: unknown): PluginManifest {
permissions,
egress,
settings: parseSettings(m.settings),
capabilities: parseCapabilities(m.capabilities),
};
}
function parseCapabilities(raw: unknown): PluginCapabilities {
if (!raw || typeof raw !== 'object') return {};
const c = raw as Record<string, unknown>;
const out: PluginCapabilities = {};
if (c.widget && typeof c.widget === 'object') {
const w = c.widget as Record<string, unknown>;
const slot = optStr(w.slot);
if (slot && slot !== 'sidebar' && slot !== 'hero') throw new ManifestError(`invalid widget slot "${slot}"`);
out.widget = {
title: optStr(w.title),
defaultSize: optStr(w.defaultSize),
slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
};
}
return out;
}
function parseSettings(raw: unknown): ManifestSettingField[] {
if (!Array.isArray(raw)) return [];
return raw
@@ -15,6 +15,7 @@ interface ActivePlugin {
name: string;
type: string;
icon: string | null;
slot: 'sidebar' | 'hero';
}
@Controller('api/plugins')
@@ -23,9 +24,19 @@ export class PluginsFeedController {
@Get()
list(): { plugins: ActivePlugin[] } {
if (!pluginsEnabled()) return { plugins: [] };
const plugins = db
.prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
.all() as ActivePlugin[];
const rows = db
.prepare("SELECT id, name, type, icon, capabilities FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
.all() as Array<Omit<ActivePlugin, 'slot'> & { capabilities: string }>;
const plugins = rows.map(({ capabilities, ...p }) => ({ ...p, slot: slotOf(capabilities) }));
return { plugins };
}
}
function slotOf(capabilities: string): 'sidebar' | 'hero' {
try {
const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
return c.widget?.slot === 'hero' ? 'hero' : 'sidebar';
} catch {
return 'sidebar';
}
}
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
+1 -1
View File
@@ -27,7 +27,7 @@ beforeEach(() => {
db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
@@ -47,3 +47,17 @@ describe('parseManifest', () => {
expect(() => parseManifest(input)).toThrow(re as RegExp);
});
});
describe('parseManifest capabilities', () => {
it('parses a hero widget slot and defaults to sidebar', () => {
const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
expect(hero.capabilities.widget?.slot).toBe('hero');
const plain = parseManifest({ ...base, capabilities: { widget: {} } });
expect(plain.capabilities.widget?.slot).toBe('sidebar');
expect(parseManifest(base).capabilities).toEqual({});
});
it('rejects an unknown widget slot', () => {
expect(() => parseManifest({ ...base, capabilities: { widget: { slot: 'floating' } } })).toThrow(ManifestError);
});
});
@@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', updated_at TEXT,
sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
@@ -66,11 +66,20 @@ describe('PluginsFeedController (client feed)', () => {
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]);
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]);
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(feed.list().plugins).toEqual([]);
});
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController().list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
});
describe('PluginsController M2 endpoints', () => {