feat(plugins): give plugins the surface, insets and mobile palette they need (#1886)

* Tell a plugin where it is mounted

A plugin sits in one of six host surfaces and cannot see which: a trip tab and
the plugin page fill their container and scroll themselves, a dashboard widget
and the settings frame report their height, a detail slot is a narrow column in
someone else's panel. The contracts differ, and on a filling surface the height
report is discarded, so a plugin cannot even discover the difference by trying.

trek:context now carries viewport: the surface by name, phone or desktop, the
fill flag, and the insets the host already keeps clear. The insets describe
space that is held free, not padding to add — the mobile trip tab is the only
surface with chrome floating over it, and the numbers come from the same
variables the native tabs use so the two cannot drift.

* Seat the plugin tab like a native one

The mobile plugin tab kept clearance at the bottom and none at the top, so the
plugin's first row sat under the status bar and behind the floating back button.
The bottom value was a copied 84 that ignored the safe area on the devices that
have one, and the padding sat on the container rather than in the scroller.

Both clearances now come from the variables TabScroller uses, so the tab cannot
drift away from the native ones. The frame also states its surface, and pins
color-scheme for the reason both settings mounts already pin it: Chromium paints
a white canvas behind a transparent frame otherwise.

The tab also had no name anywhere on screen. It is not in the dock, so nothing
lit up there either, and the header had a branch for every other tab. It gets
the same treatment, built from the tab entry the planner already assembles.

* Keep the widget card intact on the phone

The card is built from the dashboard's glass vocabulary, which is scoped to
.trek-dash. The phone dashboard is a different route and mounts these widgets
outside that subtree, so background, border, shadow and blur all resolved empty
and the widget stood on the gradient as a bare title over an iframe. Each value
now falls back to the mobile token the surrounding cards are made of.

* Give the design kit a mobile layer

The kit was written for the desktop and stayed there: a filled card with a drop
shadow where the mobile design is translucent and border-led, inputs at 13px
which make iOS Safari zoom on focus and never zoom back, feedback on :hover
which never fires on a touchscreen, and one media query in the whole stylesheet.

It reads the new viewport field now and sets data-form-factor, data-surface and
data-fill on the document, plus the insets as variables. Everything mobile hangs
off data-form-factor="phone", so nothing changes on the desktop: the card loses
its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to
16px, and pressing something scales it instead of waiting for a hover that will
not come. A filling surface anchors the body and scrolls an inner element,
because its height report is discarded by the host.

Plus the pieces a v4 screen is actually made of, so an author does not have to
reverse-engineer them: eyebrow, count pill, section header, status dot, round
icon button, field, segmented control. Each falls back to a global token where
the mobile family is absent, so they work on both.

* Document what a plugin needs on a phone

The guide had nothing on the subject: no mention of the second token family, of
who scrolls, of what sizes to expect, or that the kit brings almost nothing
responsive. It also promised more than it delivered, claiming the palette was
complete and that applying it matches the host "exactly" — fonts are named, not
shipped, so that was never true.

The new section covers the six surfaces and which of them fill versus report a
height, what insets mean (space already held free, not padding to add), the
mobile palette with the branch-on-absence pattern, and the four rules that
decide whether it feels native. It also says plainly that the dev preview cannot
show a phone width yet.
This commit is contained in:
Maurice
2026-08-11 16:51:02 +02:00
committed by GitHub
parent d1e3d8a75a
commit 6fe89588c2
17 changed files with 433 additions and 30 deletions
@@ -342,7 +342,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{dayDetailPlugins.map((p) => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -451,7 +451,7 @@ export default function PlaceInspector({
const tid = (place as { trip_id?: number | string }).trip_id
return (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} surface="detail-slot" />
</div>
)
})}
@@ -443,7 +443,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '0 14px 14px' }}>
{detailPlugins.map(p => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -623,7 +623,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{detailPlugins.map(p => (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
</div>
))}
</div>
@@ -115,7 +115,7 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
<button onClick={() => setFrame(null)} className="text-content-muted hover:text-content"><X size={16} /></button>
</div>
<div className="flex-1 min-h-0">
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill />
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill surface="action-frame" />
</div>
</div>
</div>
@@ -109,6 +109,58 @@ describe('PluginFrame', () => {
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
});
it('FE-PLUGINS-FRAME-005a: the context says which surface the frame sits in and what shape it takes', () => {
// Without this a plugin cannot tell a full-height tab from a widget that
// reports its own height — and on a filling surface the height report is
// dropped, so it never finds out by trying.
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.viewport).toMatchObject({ surface: 'trip-tab', fill: true, formFactor: 'desktop' });
expect((ctx!.viewport as { insets: unknown }).insets).toEqual({ top: 0, bottom: 0 });
});
it('FE-PLUGINS-FRAME-005b: a widget reports its own height, and says so', () => {
const { container } = render(<PluginFrame pluginId="demo" surface="dashboard-widget" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.viewport).toMatchObject({ surface: 'dashboard-widget', fill: false });
});
it('FE-PLUGINS-FRAME-005c: the mobile palette is read off the mobile shell, not the document element', () => {
// The --m-* family is scoped to .m-root. Reading it at documentElement — where
// every other token lives — yields nothing, which is why it never reached a
// plugin before.
const shell = document.createElement('div');
shell.className = 'm-root';
shell.style.setProperty('--m-card', 'rgba(255,255,255,.55)');
shell.style.setProperty('--m-ink', '#101013');
document.body.appendChild(shell);
try {
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx!.tokens).toMatchObject({ '--m-card': 'rgba(255,255,255,.55)', '--m-ink': '#101013' });
} finally {
shell.remove();
}
});
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
+93 -2
View File
@@ -10,6 +10,7 @@ import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useIsPhone } from '../../mobile/useIsPhone'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
@@ -21,6 +22,31 @@ import { addListener, removeListener } from '../../api/websocket'
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
// bakes those values instead (they don't vary with the accent, only light/dark).
/**
* The mobile palette (client/src/mobile/mobile.css) is a SECOND, unrelated token
* family, and it is scoped to `.m-root` rather than to the document element. That
* scope is the reason it cannot simply join the list below: `readThemeTokens`
* measures at documentElement, where every one of these resolves empty — the same
* trap the comment above describes for `--glass-*`. They are read off the mobile
* shell instead, when one is mounted.
*
* A plugin sitting inside the mobile shell needs these to match its surroundings;
* the global palette above describes the desktop chrome and looks foreign there.
*/
const MOBILE_TOKEN_VARS = [
// ink + ground
'--m-ink', '--m-muted', '--m-faint', '--m-bg', '--m-scr',
// glass surfaces the mobile design is built from
'--m-glass', '--m-gbr', '--m-card', '--m-cbr', '--m-inner', '--m-inbr',
'--m-sheet', '--m-sheetop', '--m-shbr',
// action + accents
'--m-act', '--m-actfg', '--m-dim', '--m-ic', '--m-rowbr', '--m-avbr', '--m-knob',
// status canon (theme independent, but a plugin should not restate it)
'--m-st-confirmed', '--m-st-pending', '--m-st-info', '--m-st-danger', '--m-st-neutral',
// chrome metrics
'--m-safe-top',
]
const TOKEN_VARS = [
// surfaces
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
@@ -39,6 +65,9 @@ const TOKEN_VARS = [
// radii, type, misc
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
// How much a host surface keeps clear at the bottom. Global, unlike the rest of
// the mobile metrics, and useful on both form factors.
'--bottom-nav-h',
]
function readThemeTokens(): Record<string, string> {
const cs = getComputedStyle(document.documentElement)
@@ -47,9 +76,54 @@ function readThemeTokens(): Record<string, string> {
const val = cs.getPropertyValue(v).trim()
if (val) out[v] = val
}
// Measured on the mobile shell, because that is where the mobile palette is
// declared. Absent on desktop, and then these keys are simply absent too — a
// plugin checks for them rather than assuming both families are present.
const mobileRoot = document.querySelector('.m-root')
if (mobileRoot) {
const mcs = getComputedStyle(mobileRoot)
for (const v of MOBILE_TOKEN_VARS) {
const val = mcs.getPropertyValue(v).trim()
if (val) out[v] = val
}
}
return out
}
/** Where a frame is mounted. Part of the plugin contract, so the names are stable. */
export type PluginSurface =
| 'trip-tab' // a plugin's own tab inside a trip (fills, scrolls itself)
| 'plugin-page' // /plugins/:id (fills, scrolls itself)
| 'dashboard-widget'// a card on the dashboard (reports its height)
| 'user-settings' // the plugin's settings.html (reports its height)
| 'detail-slot' // inside a place/day/reservation panel (reports its height)
| 'action-frame' // the modal a table action opens (fills)
/**
* What the host already keeps clear around this frame, in CSS pixels.
*
* This describes space the plugin does NOT have to account for; it is not a
* request to add padding. The mobile trip tab is the only surface with floating
* chrome above and below it, and the numbers come from the same variables the
* native tabs use, so the two can never drift apart.
*/
function readInsets(surface: PluginSurface | undefined, phone: boolean): { top: number; bottom: number } {
if (!phone || surface !== 'trip-tab') return { top: 0, bottom: 0 }
const mobileRoot = document.querySelector('.m-root')
if (!mobileRoot) return { top: 0, bottom: 0 }
const cs = getComputedStyle(mobileRoot)
const px = (value: string, fallback: number) => {
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : fallback
}
// Mirrors TabScroller: the controls end 58px below the safe-top anchor, and the
// dock plus its breathing room is what --bottom-nav-h already expresses.
return {
top: px(cs.getPropertyValue('--m-safe-top'), 12) + 58,
bottom: px(cs.getPropertyValue('--bottom-nav-h'), 84) + 22,
}
}
/**
* The host's current appearance state, mirrored from the attributes applyAppearance
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
@@ -97,6 +171,13 @@ interface PluginFrameProps {
* below — right for dashboard widgets, wrong for a page.
*/
fill?: boolean
/**
* Which host surface this frame is mounted in. Handed to the plugin verbatim,
* because the contract differs per surface and a plugin cannot see where it
* sits: a page fills and scrolls itself, a widget reports its height and gets
* it honoured, a detail slot is a narrow column inside someone else's panel.
*/
surface?: PluginSurface
className?: string
title?: string
/**
@@ -208,7 +289,8 @@ function createPluginSessionStorageKeys(
}
}
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) {
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, surface, className, title, path = 'index.html' }: PluginFrameProps) {
const isPhone = useIsPhone()
const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
// window identity keeps matching our iframe afterwards. Track loads and refuse
@@ -282,6 +364,15 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// mirror the same look and accessibility choices as the host.
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
appearance: readAppearance(),
// Where this frame sits and what shape it is expected to take. Without it a
// plugin cannot tell a full-height tab from a widget that reports its own
// height, and a height report is silently dropped on a filling surface.
viewport: {
surface: surface ?? null,
formFactor: isPhone ? 'phone' : 'desktop',
fill,
insets: readInsets(surface, isPhone),
},
formats: {
locale,
currency: displayCurrency,
@@ -292,7 +383,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
blurBookingCodes: Boolean(settings.blur_booking_codes),
},
tokens: readThemeTokens(),
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings])
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings, surface, fill, isPhone])
useEffect(() => {
const frame = frameRef.current
@@ -18,13 +18,19 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
{plugins.map((p) => (
<div
key={p.id}
// The glass vocabulary (--glass-*, --r-xl, --ink-3) is scoped to
// .trek-dash. The phone dashboard is a different route and mounts these
// widgets outside that subtree, so every one of those resolved empty and
// the card lost its background, border, shadow and blur — a bare title
// over the gradient. Each now falls back to a mobile token, which is
// what the surrounding cards on that screen are made of.
style={{
background: 'var(--glass-bg)',
border: '1px solid var(--glass-border)',
background: 'var(--glass-bg, var(--m-card))',
border: '1px solid var(--glass-border, var(--m-cbr))',
borderRadius: 'var(--r-xl, 20px)',
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
boxShadow: 'var(--glass-shadow, 0 16px 44px -20px rgba(0,0,0,.28)), var(--glass-highlight, 0 0 0 0 transparent)',
backdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
WebkitBackdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
overflow: 'hidden',
}}
>
@@ -32,7 +38,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
color: 'var(--ink-3)',
color: 'var(--ink-3, var(--m-faint))',
}}
>
<PluginIcon name={p.icon} size={14} style={{ flexShrink: 0 }} />
@@ -40,7 +46,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
</div>
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
<div style={{ minHeight: 60 }}>
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} surface="dashboard-widget" />
</div>
</div>
))}
@@ -242,7 +242,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
white canvas behind the transparent frame (same trap .hero-overlay-frame
guards against), which glares in dark mode. */}
<div className="min-h-[120px]">
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
</div>
</div>
)
@@ -288,7 +288,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
white canvas behind the transparent frame (same trap .hero-overlay-frame
guards against), which glares in dark mode. */}
<div className="min-h-[120px]">
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
</div>
</MSetCard>
)
@@ -458,6 +458,24 @@ export default function MTripShell({
</div>
)}
{/* A plugin tab is the only one that used to arrive without a name: it is
not in the dock, so nothing was lit up there either, and the screen
gave no clue which plugin was open. Same treatment as the others, from
the tab entry the planner already builds (id, label, icon). */}
{trTab.startsWith('plugin:') && (() => {
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
if (!tab) return null
const Icon = tab.icon
return (
<div className="pointer-events-none absolute left-[52px] right-[52px] top-1/2 flex -translate-y-1/2 items-center justify-center gap-[7px]">
<div className="flex min-w-0 items-center gap-[7px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[13px] py-[7px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
{Icon && <Icon size={14} strokeWidth={2} className="flex-none text-m-muted" />}
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{tab.label}</span>
</div>
</div>
)
})()}
{trTab === 'plan' ? (
<MIconBtn
ariaLabel={view === 'plan' ? t('mobileTrip.mapView') : t('mobileTrip.listView')}
@@ -18,12 +18,27 @@ import PluginFrame from '../../../../components/Plugins/PluginFrame'
*/
export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProps) {
// Trip-page plugin tab — the same sandboxed frame the desktop planner mounts,
// filling the panel between the top chrome and the dock. The bottom padding
// mirrors TabScroller's safe-area clearance so the frame never hides under it.
// between the floating top controls and the dock. Both clearances come from the
// variables TabScroller uses, so the frame cannot drift away from the native
// tabs: the top one kept the plugin's first row under the z-42 back button, and
// the bottom one was a copy of 84px that ignored the safe area on the devices
// that have one. The frame also states its surface, so the plugin knows it is
// expected to fill and scroll itself rather than report a height.
//
// color-scheme is pinned for the same reason both settings mounts pin it:
// Chromium paints a white canvas behind a transparent frame otherwise.
if (tab.startsWith('plugin:')) {
return (
<div className="absolute inset-0" style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 84px)' }}>
<PluginFrame pluginId={tab.slice('plugin:'.length)} tripId={planner.tripId != null ? String(planner.tripId) : null} fill className="h-full w-full" />
<div
className="absolute inset-0 pt-[calc(var(--m-safe-top,12px)+58px)] pb-[calc(var(--bottom-nav-h,84px)+22px)]"
>
<PluginFrame
pluginId={tab.slice('plugin:'.length)}
tripId={planner.tripId != null ? String(planner.tripId) : null}
fill
surface="trip-tab"
className="h-full w-full [color-scheme:light]"
/>
</div>
)
}
+1 -1
View File
@@ -419,7 +419,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
{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" />
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} surface="dashboard-widget" className="hero-overlay-frame" />
))}
</div>
)}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function PluginPage(): React.ReactElement {
return (
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
<PluginFrame pluginId={pluginId} title={plugin?.name} fill className="w-full h-full" />
<PluginFrame pluginId={pluginId} title={plugin?.name} fill surface="plugin-page" className="w-full h-full" />
</div>
</PageShell>
)
+1 -1
View File
@@ -784,7 +784,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill className="w-full h-full" />
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill surface="trip-tab" className="w-full h-full" />
</div>
)}
</div>
@@ -20,8 +20,8 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default:
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} />
default: ({ pluginId, tripId, fill, surface }: { pluginId: string; tripId: string | null; fill?: boolean; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} data-surface={surface} />
),
}))
@@ -71,8 +71,20 @@ describe('MTripTabPanel', () => {
expect(frame).toHaveAttribute('data-plugin', 'trip-todos')
expect(frame).toHaveAttribute('data-trip', '1')
expect(frame).toHaveAttribute('data-fill', 'true')
// The frame keeps the dock clearance the scroll body uses.
expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom')
// Both clearances come from the variables TabScroller uses, so the frame
// cannot drift away from the native tabs. The top one was missing entirely,
// which put the plugin's first row under the floating back button; the bottom
// one was a hard-coded 84 that ignored the safe area.
const wrapper = frame.parentElement as HTMLElement
expect(wrapper.className).toContain('--m-safe-top')
expect(wrapper.className).toContain('--bottom-nav-h')
})
it('FE-MOB-TABPANEL-007b: tells the plugin which surface it is mounted in', () => {
// A trip tab fills and scrolls itself; a widget reports its height. The plugin
// cannot see the difference from inside the frame.
renderTab('plugin:trip-todos')
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-surface', 'trip-tab')
})
it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => {
+131
View File
@@ -279,6 +279,125 @@ body.trek-ui {
@media (max-width: 639px) {
.trek-modal-enter { animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1); }
}
/* Mobile layer ------------------------------------------------------------- *
* TREK 4 gives the phone its own design: a translucent, border-led surface over
* a gradient, no drop shadows, and touch feedback rather than hover. The host
* hands the mobile palette (--m-*) down with the rest of the tokens whenever a
* frame is mounted inside the mobile shell, so these rules only restate the
* SHAPE — the colours come from the same variables the native screens use.
*
* Everything here is keyed on [data-form-factor="phone"], which the host sets
* from the viewport it reports. On desktop not a single rule below applies. */
[data-form-factor="phone"] .trek-ui,
[data-form-factor="phone"] body.trek-ui { font-size: 15px; }
/* The card is the workhorse. On the phone it is a translucent tile with a hairline
border and no shadow — a drop shadow over the gradient reads as a sticker. */
[data-form-factor="phone"] .trek-card {
background: var(--m-card, var(--bg-card));
border: 1px solid var(--m-cbr, var(--border-primary));
border-radius: 16px;
box-shadow: none;
padding: 14px;
-webkit-backdrop-filter: blur(30px) saturate(1.8);
backdrop-filter: blur(30px) saturate(1.8);
}
[data-form-factor="phone"] .trek-glass {
background: var(--m-glass, var(--glass-bg));
border-color: var(--m-gbr, var(--glass-border));
border-radius: 20px;
box-shadow: none;
padding: 16px;
}
/* Touch has no hover. The lift on hover never fires on a phone and the pressed
state never existed, so a tap gave no feedback at all. */
[data-form-factor="phone"] .trek-card.trek-interactive:hover,
[data-form-factor="phone"] .trek-glass.trek-interactive:hover { transform: none; box-shadow: none; }
[data-form-factor="phone"] .trek-interactive:active,
[data-form-factor="phone"] .trek-btn:active,
[data-form-factor="phone"] .trek-row:active { transform: scale(.985); opacity: .9; }
[data-form-factor="phone"] .trek-btn:hover { filter: none; }
/* 44px is the smallest target that reliably hits on a phone. */
[data-form-factor="phone"] .trek-btn { min-height: 44px; border-radius: 12px; }
[data-form-factor="phone"] .trek-row { min-height: 48px; }
/* An input below 16px makes iOS Safari zoom the page on focus, and the zoom does
not come back. This is the single most common mobile bug in embedded UI. */
[data-form-factor="phone"] .trek-input,
[data-form-factor="phone"] .trek-select,
[data-form-factor="phone"] .trek-textarea { font-size: 16px; min-height: 44px; border-radius: 12px; }
/* Ink follows the mobile palette where it exists. */
[data-form-factor="phone"] .trek-title { color: var(--m-ink, var(--text-primary)); }
[data-form-factor="phone"] .trek-sub,
[data-form-factor="phone"] .trek-muted { color: var(--m-muted, var(--text-muted)); }
/* A filling surface owns its own scrolling: the host will not scroll for it, and
a height report is discarded. Anchor the body and scroll the content. */
[data-fill] .trek-ui, [data-fill] body.trek-ui { height: 100%; overflow: hidden; }
[data-fill] .trek-scroll {
height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Building blocks the native mobile screens are made of, so a plugin does not
have to reverse-engineer them from screenshots. */
.trek-eyebrow {
font-size: 10px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;
color: var(--m-faint, var(--text-faint));
}
.trek-countpill {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
background: var(--m-ic, var(--bg-tertiary)); color: var(--m-muted, var(--text-muted));
font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.trek-sectionhead {
display: flex; align-items: center; gap: 7px; width: 100%;
margin: 15px 0 2px; padding: 0 2px; background: none; border: 0;
font: inherit; text-align: left; cursor: pointer;
}
.trek-statusdot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--m-st-neutral, var(--text-faint)); }
.trek-statusdot[data-status="confirmed"] { background: var(--m-st-confirmed, var(--success)); }
.trek-statusdot[data-status="pending"] { background: var(--m-st-pending, var(--warning)); }
.trek-statusdot[data-status="info"] { background: var(--m-st-info, var(--info)); }
.trek-statusdot[data-status="danger"] { background: var(--m-st-danger, var(--danger)); }
.trek-iconbtn {
display: inline-grid; place-items: center; flex: none;
width: 38px; height: 38px; border-radius: 999px; cursor: pointer;
background: var(--m-ic, var(--bg-tertiary));
border: 1px solid var(--m-gbr, var(--border-primary));
color: var(--m-ink, var(--text-primary));
}
.trek-iconbtn:active { transform: scale(.94); }
.trek-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.trek-field > .trek-field-label {
font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
color: var(--m-faint, var(--text-faint));
}
.trek-field > .trek-field-value {
font-size: 13px; font-weight: 600; color: var(--m-ink, var(--text-primary));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trek-segmented {
display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
background: var(--m-glass, var(--bg-tertiary));
border: 1px solid var(--m-gbr, var(--border-primary));
}
.trek-segmented > button {
border: 0; background: none; cursor: pointer; font: inherit;
padding: 8px 16px; border-radius: 999px; white-space: nowrap;
font-size: 13px; font-weight: 500; color: var(--m-ink, var(--text-primary));
}
.trek-segmented > button[aria-pressed="true"],
.trek-segmented > button.on {
background: var(--m-act, var(--accent)); color: var(--m-actfg, var(--accent-text)); font-weight: 600;
}
.trek-backdrop-enter { animation: trek-backdrop-enter 180ms var(--trek-ease-quint); }
.trek-toast-enter { animation: trek-toast-enter 260ms var(--trek-ease-quint); will-change: transform, opacity; }
.trek-pie-reveal {
@@ -371,6 +490,18 @@ export const TREK_THEME_JS = `(function () {
if (m.theme) { docEl.setAttribute('data-theme', m.theme); }
if (m.locale) { docEl.setAttribute('lang', m.locale); }
docEl.setAttribute('dir', m.dir === 'rtl' ? 'rtl' : 'ltr');
// Where this frame sits, and in what shape. data-form-factor drives the
// mobile layer of this stylesheet; data-surface lets a plugin tell a
// full-height tab from a widget that reports its height. The insets are the
// space the HOST already keeps clear — exposed so a plugin can align its own
// sticky chrome with the host's, not so it adds padding on top.
var vp = m.viewport || {};
if (vp.formFactor) { docEl.setAttribute('data-form-factor', vp.formFactor); }
if (vp.surface) { docEl.setAttribute('data-surface', vp.surface); }
setFlag('data-fill', vp.fill);
var ins = vp.insets || {};
docEl.style.setProperty('--trek-inset-top', (ins.top || 0) + 'px');
docEl.style.setProperty('--trek-inset-bottom', (ins.bottom || 0) + 'px');
var t = m.tokens || {};
for (var k in t) {
if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); }
+81 -3
View File
@@ -551,13 +551,15 @@ declared), no popups.
### Matching the TREK look by hand (`m.tokens`)
`tokens` is the whole global palette resolved for the **current** theme — surfaces
`tokens` is the global palette resolved for the **current** theme — surfaces
(`--bg-card`, `--bg-hover`, …), text (`--text-primary`/`-secondary`/`-muted`/`-faint`),
borders, the **accent family** (`--accent`, `--accent-text`, `--accent-hover`,
`--accent-subtle`), semantic + soft fills (`--success`/`--danger`/`--warning`/`--info`
`-soft`), shadows (`--shadow-*`), radii (`--radius-*`) and fonts (`--font-system`).
Apply them as CSS variables and your UI matches the host exactly — in both themes and
under a custom accent or high-contrast — instead of hard-coding a palette that drifts:
Apply them as CSS variables and your UI follows the host through a theme toggle, a
custom accent and high-contrast, instead of hard-coding a palette that drifts. (Fonts
are named, not shipped: the files live in the host bundle, so declare your own or fall
back to a system stack.)
```js
function applyContext(m) {
@@ -579,6 +581,82 @@ bakes those, since they only change with light/dark, not the accent.) Honour
and auto-size to the height you report via `trek:resize`, so render flush and
transparent — the design kit reports your height for you.
## On a phone
TREK 4 gives the phone its own design, and your plugin is mounted inside it. Two things
follow from that, and both arrive in `trek:context` under `viewport`:
```js
m.viewport = {
surface: 'trip-tab', // where you are mounted, see the table below
formFactor: 'phone', // 'phone' | 'desktop'
fill: true, // true = fill the host container, false = report your height
insets: { top: 82, bottom: 106 }, // px the host ALREADY keeps clear around you
}
```
### Know which surface you are on
| `surface` | Shape | Notes |
|---|---|---|
| `trip-tab` | fills | Your own tab in a trip. You scroll yourself. |
| `plugin-page` | fills | `/plugins/:id`. You scroll yourself. |
| `dashboard-widget` | reports height | A card on the dashboard. |
| `user-settings` | reports height | Your `settings.html`. |
| `detail-slot` | reports height | A narrow column inside a place/day/reservation panel. |
| `action-frame` | fills | The modal a table action opens. |
This matters because the contract differs: on a **filling** surface your `trek:resize`
height is deliberately ignored, so give your document `height: 100%` and put the
scrolling on an inner element. On a **reporting** surface let the content decide the
height and never set `100%`, or the host reserves a screenful for two lines of text.
The design kit does both for you when it sees `viewport.fill`.
### `insets` is not padding you have to add
It is the space the host is *already* keeping clear. On the mobile trip tab, floating
controls hover above your frame and a translucent dock hovers below it, and the host
holds both areas free for you. Use the numbers to line your own sticky header up with
the host's chrome, not to add margin — that would double the gap.
### The mobile palette
Inside the mobile shell, `tokens` carries a **second** family alongside the global one:
`--m-ink`, `--m-muted`, `--m-faint`, `--m-bg`, `--m-card`, `--m-cbr`, `--m-glass`,
`--m-gbr`, `--m-inner`, `--m-act`, `--m-actfg`, `--m-ic`, `--m-rowbr`, the status canon
(`--m-st-confirmed`, `--m-st-pending`, `--m-st-info`, `--m-st-danger`, `--m-st-neutral`)
and `--m-safe-top`. These are what the native mobile screens are built from; the global
palette describes the desktop chrome and looks foreign next to them.
They are **absent on desktop**, so branch on their presence rather than assuming both
families are there:
```css
.card {
background: var(--m-card, var(--bg-card));
border: 1px solid var(--m-cbr, var(--border-primary));
}
```
### Four rules that decide whether it feels native
1. **Inputs at 16px or larger.** Below that, iOS Safari zooms the page on focus and does
not zoom back. This is the most common mobile bug in embedded UI.
2. **Touch feedback on `:active`, not `:hover`.** A hover rule never fires on a phone, so
a tap gives no response at all.
3. **Targets at least 44px.** Anything smaller is a coin toss with a thumb.
4. **No drop shadows on the mobile surface.** The design is translucent and border-led
over a gradient; a shadow reads as a sticker glued on top.
The design kit applies all four automatically once `data-form-factor="phone"` is set,
which it does from `viewport`. If you style everything yourself, they are on you.
### Seeing it
`trek-plugin dev` previews at a desktop size. Until that grows a phone width, resize the
browser window and use your browser's device emulation — the frame is a real browsing
context, so media queries inside it measure the frame, and they work.
## Settings
Declare settings in the manifest; TREK renders the form (you write no settings