diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 1d2ade4d7..e430ad24b 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -342,7 +342,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{dayDetailPlugins.map((p) => (
- +
))}
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 8d53e9567..a666cde90 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -451,7 +451,7 @@ export default function PlaceInspector({ const tid = (place as { trip_id?: number | string }).trip_id return (
- +
) })} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index ca204f824..c37d14f1a 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -443,7 +443,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{detailPlugins.map(p => (
- +
))}
@@ -623,7 +623,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> {detailPlugins.map(p => (
- +
))}
diff --git a/client/src/components/Plugins/PluginContributions.tsx b/client/src/components/Plugins/PluginContributions.tsx index d9b87920c..6972e1697 100644 --- a/client/src/components/Plugins/PluginContributions.tsx +++ b/client/src/components/Plugins/PluginContributions.tsx @@ -115,7 +115,7 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
- +
diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index 9aea91891..34b28c50a 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -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(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | 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(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | 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(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | 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(); const iframe = container.querySelector('iframe')!; diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index c97dde6fd..f773d3731 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -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 { const cs = getComputedStyle(document.documentElement) @@ -47,9 +76,54 @@ function readThemeTokens(): Record { 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 , 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(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 diff --git a/client/src/components/Plugins/PluginWidgets.tsx b/client/src/components/Plugins/PluginWidgets.tsx index 663e31d8e..57a849a5f 100644 --- a/client/src/components/Plugins/PluginWidgets.tsx +++ b/client/src/components/Plugins/PluginWidgets.tsx @@ -18,13 +18,19 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act {plugins.map((p) => (
@@ -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))', }} > @@ -40,7 +46,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
- +
))} diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index 2f58c0a6e..638054c1f 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -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. */}
- +
) diff --git a/client/src/mobile/screens/settings/MSettingsPlugins.tsx b/client/src/mobile/screens/settings/MSettingsPlugins.tsx index a6dc4efa6..8fd2c4498 100644 --- a/client/src/mobile/screens/settings/MSettingsPlugins.tsx +++ b/client/src/mobile/screens/settings/MSettingsPlugins.tsx @@ -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. */}
- +
) diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index ef20429d5..1526f7eda 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -458,6 +458,24 @@ export default function MTripShell({ )} + {/* 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 ( +
+
+ {Icon && } + {tab.label} +
+
+ ) + })()} + {trTab === 'plan' ? ( - +
+
) } diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index be8e70887..f1613eb9c 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -419,7 +419,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {heroPlugins.length > 0 && ( )} diff --git a/client/src/pages/PluginPage.tsx b/client/src/pages/PluginPage.tsx index 3b2d5f69f..cf403adeb 100644 --- a/client/src/pages/PluginPage.tsx +++ b/client/src/pages/PluginPage.tsx @@ -27,7 +27,7 @@ export default function PluginPage(): React.ReactElement { return (
- +
) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 463b8005d..200b6ef67 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -784,7 +784,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {activeTab.startsWith('plugin:') && (
- +
)} diff --git a/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx index 57a429251..f211158ef 100644 --- a/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx +++ b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx @@ -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 }) => ( -
+ default: ({ pluginId, tripId, fill, surface }: { pluginId: string; tripId: string | null; fill?: boolean; surface?: string }) => ( +
), })) @@ -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', () => { diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts index 778ebd2d6..ac09c6758 100644 --- a/plugin-sdk/src/ui/kit.ts +++ b/plugin-sdk/src/ui/kit.ts @@ -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]); } diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index c290545c3..327f18a87 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -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