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;