mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c065356dc | |||
| e0cca62347 | |||
| 1222fb3901 | |||
| 142aa61295 | |||
| 338548bf7f | |||
| a94464a953 | |||
| 88e6f332d3 | |||
| d07b8ba56e | |||
| e9f7d5e77b | |||
| 9a7c520106 | |||
| 5ccd4674ac | |||
| d1b8cb20a3 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae |
@@ -53,3 +53,54 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
|
||||
## Typefaces: Studio books and the app
|
||||
|
||||
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
|
||||
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
|
||||
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
|
||||
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
|
||||
copyright notice of the family's authors in its `LICENSE` file.
|
||||
|
||||
Both licences allow a font to be bundled with software and embedded in a
|
||||
document made with it, so a printed or exported book may carry the faces it is
|
||||
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
| Family | Package | Licence |
|
||||
|---|---|---|
|
||||
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
|
||||
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
|
||||
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
|
||||
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
|
||||
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
|
||||
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
|
||||
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
|
||||
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
|
||||
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
|
||||
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
|
||||
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
|
||||
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
|
||||
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
|
||||
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
|
||||
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
|
||||
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
|
||||
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
|
||||
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
|
||||
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
|
||||
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
|
||||
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
|
||||
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
|
||||
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
|
||||
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
|
||||
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
|
||||
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
|
||||
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
|
||||
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
|
||||
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
|
||||
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
|
||||
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
|
||||
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
|
||||
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
|
||||
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
|
||||
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Which font files the service worker installs with the app.
|
||||
*
|
||||
* The precache takes every woff and woff2 the build emits, and with the book's
|
||||
* catalogue at thirty four families that is every weight, both styles and
|
||||
* every script subset of each: megabytes fetched at install by every user,
|
||||
* most of it for faces no book of theirs is set in. So the book's faces are
|
||||
* held to this:
|
||||
*
|
||||
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
|
||||
* imported in src/main.tsx) stay exactly as they were.
|
||||
* - The faces the book shipped with before the catalogue grew
|
||||
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
|
||||
* still opens offline before Studio was ever opened online.
|
||||
* - Every other file of a book family, woff, other subsets, new families,
|
||||
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
|
||||
* which the Studio fills with the faces of each book it opens
|
||||
* (src/components/Studio/bookFontWarm.ts).
|
||||
*
|
||||
* Shared by vite.config.js and its test, which reads the filter against file
|
||||
* names as the build writes them.
|
||||
*/
|
||||
|
||||
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
|
||||
export const APP_FACES = {
|
||||
poppins: [300, 400, 500, 600, 700],
|
||||
museomoderno: [400, 700, 800],
|
||||
};
|
||||
|
||||
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
|
||||
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
|
||||
(a, b) => b.length - a.length
|
||||
);
|
||||
|
||||
const PRECACHED = new Map(
|
||||
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
|
||||
);
|
||||
|
||||
/**
|
||||
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
|
||||
* read against the book's packages; null for any other file.
|
||||
*/
|
||||
export function bookFontFile(url) {
|
||||
const name = url.split('/').pop() ?? '';
|
||||
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
|
||||
if (!match) return null;
|
||||
const [, head, weight, style, ext] = match;
|
||||
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
|
||||
if (!pkg) return null;
|
||||
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
|
||||
}
|
||||
|
||||
/** Whether a file of the build goes into the install-time precache. */
|
||||
export function keepInPrecache(url) {
|
||||
const file = bookFontFile(url);
|
||||
if (!file) return true;
|
||||
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
|
||||
const kept = PRECACHED.get(file.pkg);
|
||||
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
|
||||
}
|
||||
|
||||
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
|
||||
export function withoutRuntimeFonts(entries) {
|
||||
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
|
||||
}
|
||||
@@ -47,14 +47,41 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||
"@fontsource/allura": "^5.3.0",
|
||||
"@fontsource/anton": "^5.3.0",
|
||||
"@fontsource/archivo-black": "^5.3.0",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/big-shoulders-display": "^5.3.0",
|
||||
"@fontsource/bodoni-moda": "^5.3.0",
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/caveat": "^5.3.0",
|
||||
"@fontsource/cormorant-garamond": "^5.3.0",
|
||||
"@fontsource/dancing-script": "^5.3.0",
|
||||
"@fontsource/dm-mono": "^5.3.0",
|
||||
"@fontsource/dm-serif-display": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/fraunces": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/great-vibes": "^5.3.0",
|
||||
"@fontsource/homemade-apple": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/kaushan-script": "^5.3.0",
|
||||
"@fontsource/league-gothic": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/oswald": "^5.3.0",
|
||||
"@fontsource/pacifico": "^5.3.0",
|
||||
"@fontsource/parisienne": "^5.3.0",
|
||||
"@fontsource/permanent-marker": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.3.0",
|
||||
"@fontsource/sacramento": "^5.3.0",
|
||||
"@fontsource/satisfy": "^5.3.0",
|
||||
"@fontsource/shrikhand": "^5.3.0",
|
||||
"@fontsource/yellowtail": "^5.3.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2087
|
||||
"src/styles/studio.css": 2057
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
@@ -102,6 +107,17 @@ describe('the connection and the assistant', () => {
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
|
||||
const toggle = screen.getByRole('button', { name: 'Web search' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
@@ -136,7 +137,7 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
@@ -170,6 +171,19 @@ function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
{A.settings && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
|
||||
<ToggleSwitch
|
||||
on={A.settings.webSearch}
|
||||
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
|
||||
label={t('assistant.admin.webSearch')}
|
||||
/>
|
||||
</div>
|
||||
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
|
||||
@@ -40,7 +40,7 @@ function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
||||
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -92,6 +92,7 @@ function useAdminSettings(onSaved: () => void) {
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
@@ -13,6 +14,8 @@ import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { ChangeRows } from './cards/ChangeCardSlots';
|
||||
import CardHeader from './cards/parts/CardHeader';
|
||||
import { CardFoot } from './cards/parts/CardSection';
|
||||
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
||||
import { useChangeExclusions } from './cards/useChangeExclusions';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
@@ -60,6 +63,9 @@ function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, n
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of a card whose changes are all decided: the green of a done booking. */
|
||||
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
||||
|
||||
/** The waiting changes the user left on that touch the day plan. */
|
||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||
return items.flatMap((i) =>
|
||||
@@ -69,17 +75,18 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
<CardHeader
|
||||
icon={open ? ListChecks : CheckCheck}
|
||||
tone={open ? 'info' : 'success'}
|
||||
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
||||
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
count={count}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
@@ -257,25 +264,19 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<CardFoot>
|
||||
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
icon={<Check size={14} aria-hidden="true" />}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</DialogButton>
|
||||
</CardFoot>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -103,11 +103,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
<div className="min-w-0 flex-1">
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
@@ -22,7 +23,7 @@ function useRouteTripId(): number | null {
|
||||
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
|
||||
* own tab remembered first), and another page is simply navigated to. A day
|
||||
* picked in the chat (its weather) selects that day the same way, and "On
|
||||
* map" on a card brings the planner's map back first.
|
||||
* map" on a card brings the planner's map back first from a tab without one.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
@@ -54,8 +55,12 @@ export default function AssistantHost() {
|
||||
[navigate, openPlanner, tripId]
|
||||
);
|
||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
|
||||
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
|
||||
// "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab
|
||||
// shows the route on its own map. A map on screen tells the assistant its view, which is how it is known.
|
||||
const revealMap = useCallback(() => {
|
||||
if (useAssistantStore.getState().mapView?.tripId === tripId) return;
|
||||
openPlanner('?tab=plan');
|
||||
}, [openPlanner, tripId]);
|
||||
|
||||
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
@@ -129,6 +131,10 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||
@@ -16,110 +14,14 @@ type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||
const ACTION =
|
||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||
|
||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
/** "Add to trip": opens the planner's place form filled in with the place, its details loading beside it. */
|
||||
function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const { open, toggle, close, triggerRef, listId } = list;
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
|
||||
const pick = (dayId: number) => {
|
||||
close();
|
||||
void strip.suggest(place, dayId);
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<span className="inline-flex min-w-0 gap-px">
|
||||
{focusDay ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-label={t('assistant.place.pickDay')}
|
||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={`${ACTION} ${height} px-2`}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockedKey ? (
|
||||
<Tooltip label={t(blockedKey)} placement="top">
|
||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||
<span className="inline-flex min-w-0">{buttons}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
<button type="button" onClick={() => strip.open(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.add')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +121,7 @@ function PlaceAction({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||
return strip.canAdd ? <AddButton place={place} strip={strip} tall={tall} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,9 +129,10 @@ function PlaceAction({
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||
* the card shows it on the map, and resting the pointer on it pins it there
|
||||
* for a moment.
|
||||
* way on: "Add to trip" for a new place, "On map" for one the trip has. A click
|
||||
* on a new place opens the place form filled in with it (one the trip has, or
|
||||
* any where nothing can be added, shows on the map), and resting the pointer on
|
||||
* it pins it there for a moment.
|
||||
*/
|
||||
export default function AssistantPlaceCard({
|
||||
place,
|
||||
@@ -259,7 +162,7 @@ export default function AssistantPlaceCard({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.show(place)}
|
||||
onClick={() => (place.inTripPlaceId === null && strip.canAdd ? strip.open(place) : strip.show(place))}
|
||||
onFocus={() => strip.hover(place)}
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* FE-AI-PLACES-001 to FE-AI-PLACES-010
|
||||
*
|
||||
* The strip of places a search found: what a card says, that its picture only
|
||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||
* picked one, why it waits while a change is undecided, the map's part, and
|
||||
* no "+ Day" in a journey's chat.
|
||||
* ever comes from TREK's photo proxy, "Add to trip" opening the place form on
|
||||
* the day in focus, the map's part, and no "Add to trip" in a journey's chat.
|
||||
*/
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -67,7 +66,6 @@ const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||
parts,
|
||||
});
|
||||
|
||||
const addFoundPlace = vi.fn(async () => {});
|
||||
const requestCamera = vi.fn();
|
||||
const setPreviewPin = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
@@ -83,7 +81,6 @@ function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||
});
|
||||
useAssistantStore.setState({
|
||||
addFoundPlace,
|
||||
requestCamera,
|
||||
setPreviewPin,
|
||||
byTrip: {
|
||||
@@ -165,34 +162,30 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||
it('FE-AI-PLACES-004: "Add to trip" opens the place form with the place, on the day in focus', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to trip' }));
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ tripId: TRIP, dayId: 31, place: { ref: 's1:0' } });
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||
it('FE-AI-PLACES-005: a click on a new place opens the form too, without a day when none is in focus', () => {
|
||||
seed({ focusDayId: 0 });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||
fireEvent.click(screen.getByText('Trattoria s1:0'));
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ dayId: null, place: { ref: 's1:0' } });
|
||||
expect(requestCamera).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||
it('FE-AI-PLACES-006: opening the form waits for nothing: an undecided change and offline leave it on', () => {
|
||||
const waiting = answer([
|
||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||
]);
|
||||
seed({ messages: [waiting] });
|
||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
seed();
|
||||
setForcedOffline(true);
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Add to trip' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||
@@ -234,15 +227,14 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "Add to trip" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "Add to trip";
|
||||
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
|
||||
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
|
||||
});
|
||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Pick a day/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-AI-REMEMBER-001 to FE-AI-REMEMBER-002
|
||||
*
|
||||
* A note the assistant kept on its own: a quiet line naming it, the trip
|
||||
* notes read again while the answer is written, and the notes a tap away.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
|
||||
function renderLine(live: boolean) {
|
||||
const view: AssistantView = { tripId: 4, variant: 'window', density: 'compact' };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={view}>
|
||||
<AssistantRemembered note="We eat vegetarian" live={live} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantRemembered', () => {
|
||||
it('FE-AI-REMEMBER-001: names the note, and reads the notes again only while the answer is written', () => {
|
||||
const loadSettings = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
const { unmount } = renderLine(false);
|
||||
expect(screen.getByText('We eat vegetarian')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noted for this trip:')).toBeInTheDocument();
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
renderLine(true);
|
||||
expect(loadSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-REMEMBER-002: the pencil opens the trip notes', () => {
|
||||
useSettingsStore.setState({ loadSettings: vi.fn(async () => {}) });
|
||||
renderLine(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit trip notes' }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BookmarkCheck, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { useAssistantScope } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
* A note the assistant kept in the trip notes on its own, as a quiet line in
|
||||
* its answer, with the notes a tap away to change or drop it. Kept while the
|
||||
* answer is written, the notes on this device are read again, so the dialog
|
||||
* never saves over the note with what it held before.
|
||||
*/
|
||||
export default function AssistantRemembered({ note, live }: { note: string; live: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useAssistantScope();
|
||||
const loadSettings = useSettingsStore((s) => s.loadSettings);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (live) void loadSettings();
|
||||
}, [live, loadSettings]);
|
||||
|
||||
const tripId = scope.kind === 'trip' ? scope.tripId : null;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5 text-caption">
|
||||
<BookmarkCheck size={13} strokeWidth={2.2} aria-hidden="true" className="flex-none text-success" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-content-secondary">{t('assistant.memory.kept')}</span>{' '}
|
||||
<bdi className="text-content-muted">{note}</bdi>
|
||||
</span>
|
||||
{tripId !== null && (
|
||||
<Tooltip label={t('assistant.memory.edit')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={t('assistant.memory.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Pencil size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{editing && tripId !== null && <AssistantMemoryDialog tripId={tripId} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* FE-AI-SOURCES-001 to FE-AI-SOURCES-002
|
||||
*
|
||||
* The pages an answer cites from the provider's web search: a chip per page
|
||||
* with its site and title, opening in a new tab without telling the page who
|
||||
* sent the reader, and the block they form in an answer.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
|
||||
describe('AssistantSources', () => {
|
||||
it('FE-AI-SOURCES-001: names each page by its site and title, and opens it in a new tab', () => {
|
||||
render(
|
||||
<AssistantSources
|
||||
sources={[
|
||||
{ url: 'https://www.rostock.de/hafen', title: 'Stadthafen Rostock' },
|
||||
{ url: 'https://example.org/a', title: 'https://example.org/a' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('navigation', { name: 'Sources' })).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /rostock\.de/ });
|
||||
expect(link).toHaveAttribute('href', 'https://www.rostock.de/hafen');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(screen.getByText('Stadthafen Rostock')).toBeInTheDocument();
|
||||
// A title that is only the address shows the site alone.
|
||||
expect(screen.getByRole('link', { name: 'example.org' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-SOURCES-002: an answer keeps its sources as a block of their own after its text', () => {
|
||||
const blocks = messageBlocks(
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-23T10:00:00.000Z',
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'web-1', toolName: 'web_search', args: { query: 'x' }, result: 'searched' },
|
||||
{ type: 'text', text: 'Eat at the harbour.' },
|
||||
{ type: 'data-trek-sources', data: { sources: [{ url: 'https://a.example', title: 'A' }] } },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(blocks.map((block) => block.kind)).toEqual(['steps', 'text', 'sources']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AssistantSources as Sources } from '@trek/shared';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/** A page's host without "www.", as the chip names it; the whole address where it does not read as one. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages an answer drew on from the provider's web search, under its text:
|
||||
* a row of quiet chips with each page's site and title, opening in a new tab.
|
||||
* No page's icon is loaded, so showing the sources asks no site for anything.
|
||||
*/
|
||||
export default function AssistantSources({ sources }: { sources: Sources['sources'] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav aria-label={t('assistant.sources.title')} className="flex flex-col gap-1.5">
|
||||
<span className="font-geist text-caption font-bold uppercase tracking-[.08em] text-content-faint">
|
||||
{t('assistant.sources.title')}
|
||||
</span>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{sources.map((source) => (
|
||||
<li key={source.url} className="min-w-0 max-w-full">
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
title={source.title}
|
||||
className="flex min-w-0 max-w-[16rem] items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-caption text-content-secondary no-underline transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Globe size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
<span className="flex-none font-medium">{hostOf(source.url)}</span>
|
||||
{source.title && source.title !== source.url && (
|
||||
<bdi className="min-w-0 truncate text-content-muted">{source.title}</bdi>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe('AssistantSurface', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
||||
{ type: 'text', text: 'Rain on Monday, so Nishiki Market is a good pick.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -168,20 +168,6 @@
|
||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||
@keyframes trek-assistant-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.trek-assistant-pulse {
|
||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-assistant-pop,
|
||||
.trek-assistant-enter {
|
||||
@@ -190,8 +176,7 @@
|
||||
.trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
.trek-assistant-busy,
|
||||
.trek-assistant-pulse {
|
||||
.trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
.trek-assistant-shimmer {
|
||||
@@ -211,8 +196,7 @@
|
||||
[data-reduce-motion] .trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-busy,
|
||||
[data-reduce-motion] .trek-assistant-pulse {
|
||||
[data-reduce-motion] .trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-shimmer {
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('the questions an answer offers', () => {
|
||||
|
||||
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
||||
// Not the found places' chips either, but the model's own answers to its question.
|
||||
const asked = of([
|
||||
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
|
||||
{ type: 'text', text: 'Shall I plan it there?' },
|
||||
]);
|
||||
expect(followUpsFor(asked, true)).toEqual([]);
|
||||
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,11 +126,12 @@ export function followUpsFor(message: AssistantMessage, canEdit: boolean): reado
|
||||
if (fromModel.length > 0) return fromModel;
|
||||
const fromCards = cardFollowUps(message, canEdit);
|
||||
if (fromCards.length > 0) return fromCards;
|
||||
// An answer that asks the user something waits for their answer: only the model's own offer fits it.
|
||||
if (endsWithQuestion(message)) return [];
|
||||
const fromChanges = changeFollowUps(message, canEdit);
|
||||
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
// An answer that asks the user something waits for their answer, not for "tell me more".
|
||||
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('messageBlocks: found places and weather', () => {
|
||||
read('s2', []),
|
||||
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||
places('s3', [found('s3:0', { lat: 40 })]),
|
||||
{ type: 'text', text: 'Three good ones.' },
|
||||
{ type: 'text', text: 'Three good ones, Place s3:0 first.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
@@ -208,10 +208,66 @@ describe('messageBlocks: found places and weather', () => {
|
||||
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||
// The same name at the same spot, or the same Google id, shows once.
|
||||
expect(blocks[1]).toMatchObject({
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's3:0' }, { ref: 's1:0' }, { ref: 's1:1' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008d: an answer that planned a tour shows no found places, even one whose planning failed', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
places('s1', [found('s1:0', { name: 'Treseburg' })]),
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'h1',
|
||||
toolName: 'propose_hike',
|
||||
args: {},
|
||||
result: 'Router down',
|
||||
isError: true,
|
||||
},
|
||||
{ type: 'text', text: 'Treseburg is the turning point.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).not.toContain('places');
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008c: a note kept in the trip notes is a line of its own, a refused or running one a step', () => {
|
||||
const kept = (id: string, extra: Record<string, unknown>): AssistantMessagePart =>
|
||||
({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'remember_for_trip',
|
||||
args: { note: ' Vegetarian ' },
|
||||
...extra,
|
||||
}) as AssistantMessagePart;
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
kept('m1', { result: { notes: 'Vegetarian' } }),
|
||||
kept('m2', { result: 'The trip notes are full.', isError: true }),
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks).toMatchObject([
|
||||
{ kind: 'remembered', note: 'Vegetarian' },
|
||||
{ kind: 'steps', steps: [{ toolCallId: 'm2' }] },
|
||||
]);
|
||||
expect(messageBlocks(message([kept('m3', {})]), true)).toMatchObject([{ kind: 'steps' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008b: an answer that names none of the places it found shows no strip', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'It rains all day.' }]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['text']);
|
||||
// Named with other accents and case, it is the same place.
|
||||
const named = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'Try cafe nandu.' }]),
|
||||
false
|
||||
);
|
||||
expect(named.map((b) => b.kind)).toEqual(['places', 'text']);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
type AssistantPlaces,
|
||||
type AssistantSources,
|
||||
type AssistantWeatherDay,
|
||||
type TrekCard,
|
||||
} from '@trek/shared';
|
||||
@@ -66,6 +67,10 @@ type MessageBlock =
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
||||
// A note the model kept in the trip notes on its own, as a quiet line.
|
||||
| { kind: 'remembered'; key: string; note: string }
|
||||
// The pages the answer cites from the provider's web search, under its text.
|
||||
| { kind: 'sources'; key: string; sources: AssistantSources['sources'] }
|
||||
| CardsBlock;
|
||||
|
||||
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
||||
@@ -93,6 +98,70 @@ function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeE
|
||||
});
|
||||
}
|
||||
|
||||
/** A name as it is compared with the answer's words: lower case, without accents or marks. */
|
||||
function folded(text: string): string {
|
||||
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The found places the answer names, first, then the rest; null when the
|
||||
* answer is written and names none of them. A search the answer does not
|
||||
* draw on is the model's own groundwork and shows no strip.
|
||||
*/
|
||||
function namedFirst(places: readonly AssistantFoundPlace[], text: string): AssistantFoundPlace[] | null {
|
||||
if (text.trim() === '') return [...places];
|
||||
const words = folded(text);
|
||||
const named = places.filter((place) => {
|
||||
const name = folded(place.name).trim();
|
||||
return name.length >= 3 && words.includes(name);
|
||||
});
|
||||
if (named.length === 0) return null;
|
||||
return [...named, ...places.filter((place) => !named.includes(place))];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools that plan or save a tour. An answer that ran one is about the
|
||||
* tour, planned or not: the places its searches went through to find the
|
||||
* points are the model's groundwork, not suggestions, and show no strip.
|
||||
*/
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
'plan_paddle_route',
|
||||
'plan_ski_route',
|
||||
'prepare_tour_change',
|
||||
'save_tour_change',
|
||||
]);
|
||||
|
||||
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||
const REMEMBER_TOOL = 'remember_for_trip';
|
||||
|
||||
/** The note a call kept, once it is kept; null for any other call, one still running or one refused. */
|
||||
function rememberedNote(part: Extract<AssistantMessagePart, { type: 'tool-call' }>): string | null {
|
||||
if (part.toolName !== REMEMBER_TOOL || part.approval || part.result === undefined || part.isError) return null;
|
||||
const note = (part.args as { note?: unknown }).note;
|
||||
return typeof note === 'string' && note.trim() !== '' ? note.trim() : null;
|
||||
}
|
||||
|
||||
/** The strip of the answer's found places, named ones first; null when it names none or none is left. */
|
||||
function placesBlock(
|
||||
message: AssistantMessage,
|
||||
effects: readonly AssistantChangeEffect[],
|
||||
index: number
|
||||
): MessageBlock | null {
|
||||
const found = allFound(message.parts);
|
||||
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join(' ');
|
||||
const places = namedFirst(
|
||||
found.places.filter((place) => !proposed(place, effects)),
|
||||
text
|
||||
);
|
||||
if (!places || places.length === 0) return null;
|
||||
return { kind: 'places', key: `places-${index}`, found: { ...found, places } };
|
||||
}
|
||||
|
||||
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||
const byDate = new Map<string, AssistantWeatherDay>();
|
||||
@@ -256,7 +325,9 @@ export function messageBlocks(
|
||||
|
||||
// An answer about a tour shows the tour, not the places its searches went through to find it.
|
||||
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
|
||||
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
|
||||
const placesHidden =
|
||||
[...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true) ||
|
||||
message.parts.some((p) => p.type === 'tool-call' && TOUR_PLANNING_TOOLS.has(p.toolName));
|
||||
const blocks: MessageBlock[] = [];
|
||||
message.parts.forEach((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -264,9 +335,12 @@ export function messageBlocks(
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
|
||||
const found = allFound(message.parts);
|
||||
const places = found.places.filter((place) => !proposed(place, effects));
|
||||
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
||||
const strip = placesBlock(message, effects, index);
|
||||
if (strip) blocks.push(strip);
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-sources') {
|
||||
blocks.push({ kind: 'sources', key: `sources-${index}`, sources: part.data.sources });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||
@@ -286,6 +360,11 @@ export function messageBlocks(
|
||||
pushChange(blocks, item, fromStrip);
|
||||
return;
|
||||
}
|
||||
const note = rememberedNote(part);
|
||||
if (note !== null) {
|
||||
blocks.push({ kind: 'remembered', key: `remembered-${part.toolCallId}`, note });
|
||||
return;
|
||||
}
|
||||
const step: ToolStep = {
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
@@ -374,4 +453,6 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
members: { labelKey: 'assistant.change.label.members' },
|
||||
route: { labelKey: 'assistant.change.label.route' },
|
||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ function BodySlot({ item, settled }: { item: ChangeItem; settled: boolean }) {
|
||||
const view = item.card ? viewOf(item.card) : null;
|
||||
if (!view?.Body || !item.card) return null;
|
||||
return (
|
||||
<div className={`pb-1.5 ps-[38px] ${settled ? 'opacity-60' : ''}`}>
|
||||
<div className={`mt-1.5 pb-1.5 ${view.wideBody ? '' : 'ps-[38px]'} ${settled ? 'opacity-60' : ''}`}>
|
||||
<Slot item={item} view={view} name="Body" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
||||
import { Briefcase, MapPin, Radio } from 'lucide-react';
|
||||
import { Briefcase, Globe2, MapPin, Radio } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
@@ -8,8 +8,10 @@ import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
||||
import { ATLAS_PAGE } from './atlasPage';
|
||||
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
||||
@@ -112,14 +114,15 @@ export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry
|
||||
if (countries.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.domain.atlas')}>
|
||||
<CardHeader icon={Globe2} tone="info" title={t('assistant.domain.atlas')} />
|
||||
{countries.map((entry, index) => (
|
||||
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
||||
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex justify-end">
|
||||
<CardFoot>
|
||||
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
<>
|
||||
<CategoryFact row={row} />
|
||||
<DateFact row={row} />
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
{split && (
|
||||
<FactChange
|
||||
icon={Tag}
|
||||
@@ -92,13 +91,16 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The split under the row, and the expense's note under it: too long for the faint line beside the icons. */
|
||||
/** Under the row: the place it belongs to beside the split, and the expense's note under them. */
|
||||
export function ExpenseBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<SplitLine row={row} />
|
||||
<span className="flex flex-col items-start gap-1.5">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
<SplitLine row={row} />
|
||||
</span>
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
||||
const onOpen = vi.fn();
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category and day, and what an update changes', () => {
|
||||
const update = expense({
|
||||
category: 'transport',
|
||||
date: '2026-06-05',
|
||||
@@ -132,7 +131,7 @@ describe('an expense in a change row', () => {
|
||||
split: 'equal',
|
||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||
});
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||
@@ -140,6 +139,14 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
||||
expect(screen.queryByText('Window table')).toBeNull();
|
||||
// The place sits under the row, beside the split.
|
||||
expect(screen.queryByText('Lokanta')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012c: the place sits under the row beside the split, and opens on the map', () => {
|
||||
const onOpen = vi.fn();
|
||||
const row = expense({ place: { id: 12, name: 'Lokanta' } });
|
||||
inView(<ExpenseBody card={expenses([row])} item={changeItem('a', null)} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>
|
||||
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
|
||||
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||
// The provider's own web search, told as a step of its own (ASSISTANT_WEB_SEARCH_STEP).
|
||||
web_search: { stepKey: 'assistant.step.webSearch', live: { liveKey: 'assistant.step.webSearchFor', param: 'query' } },
|
||||
list_places: { stepKey: 'assistant.step.reading' },
|
||||
search_place: {
|
||||
stepKey: 'assistant.step.searching',
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface CardView<K extends TrekCardKind> {
|
||||
Facts?: ComponentType<CardSlotProps<K>>;
|
||||
Body?: ComponentType<CardSlotProps<K>>;
|
||||
ownsSubject?: boolean;
|
||||
/** The body runs the whole width of the change card instead of under the row's text (a tour's drawing). */
|
||||
wideBody?: boolean;
|
||||
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
||||
Run?: ComponentType<CardRunProps>;
|
||||
runnable?(card: TrekCardOf<K>): boolean;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { ArrowUpRight, BookOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { DAY_COLORS } from '../../../Journey/dayColors';
|
||||
import { cardTitle } from '../../../Journey/journeyCard';
|
||||
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
||||
import { LinkedJournalIcon } from '../../../Trips/LinkedJournalIcon';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import CardStrip from '../parts/CardStrip';
|
||||
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
||||
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
||||
@@ -33,9 +34,6 @@ const TITLE_BUTTON =
|
||||
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
||||
/** The phone's taller target, the title still at the top of the row. */
|
||||
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
||||
const FOOTER = 'mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption';
|
||||
const FOOTER_TITLE = 'min-w-0 flex-1 truncate text-content-muted';
|
||||
const FAINT = 'text-content-faint';
|
||||
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
||||
@@ -47,29 +45,40 @@ const TILE = 'flex-none snap-start';
|
||||
|
||||
type Open = ((entryId: number | null) => void) | null;
|
||||
|
||||
/** The journal's name and the way into it, under the card. */
|
||||
function JournalFooter({ journal, open }: { journal: JournalEntries; open: Open }) {
|
||||
/**
|
||||
* The way into the journal under its entries: in the foot bar of the card, or
|
||||
* under the strip of tiles with how many entries it holds, which the strip
|
||||
* has no band to say.
|
||||
*/
|
||||
function JournalFooter({ open, count }: { open: Open; count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const button = open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
if (count === undefined) return button ? <CardFoot>{button}</CardFoot> : null;
|
||||
return (
|
||||
<div className={FOOTER}>
|
||||
<LinkedJournalIcon size={12} className={FAINT} />
|
||||
<span title={title} className={FOOTER_TITLE}>
|
||||
<bdi>{title}</bdi>
|
||||
{count > 2 && <span className="ms-1.5 tabular-nums">{t('assistant.card.journey.entries', { count })}</span>}
|
||||
<div className="mt-1 flex items-center gap-2 text-caption">
|
||||
<span className="min-w-0 flex-1 truncate tabular-nums text-content-muted">
|
||||
{t('assistant.card.journey.entries', { count })}
|
||||
</span>
|
||||
{open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of the entries: the journal's name, and how many entries it holds once there are more than two. */
|
||||
function JournalHead({ journal }: { journal: JournalEntries }) {
|
||||
const { t } = useTranslation();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const meta = count > 2 ? t('assistant.card.journey.entries', { count }) : undefined;
|
||||
return <CardHeader icon={BookOpen} tone="info" title={title} meta={meta} />;
|
||||
}
|
||||
|
||||
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
||||
function EntryRow({
|
||||
look,
|
||||
@@ -116,10 +125,11 @@ function JournalRows({ journal, fallbackRef }: { journal: JournalEntries; fallba
|
||||
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journey.label')}>
|
||||
<JournalHead journal={journal} />
|
||||
{journal.entries.map((look, index) => (
|
||||
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
||||
))}
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -152,7 +162,7 @@ function JournalStrip({ journal, fallbackRef }: { journal: JournalEntries; fallb
|
||||
</li>
|
||||
)}
|
||||
</CardStrip>
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} count={journal.entries.length + journal.more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Backpack, Check } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import { PackedBar } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { isRunnable, mergePackingCards, rowCount } from './checklistModel';
|
||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||
|
||||
@@ -62,7 +64,7 @@ function useTitle(card: PackingCard): string {
|
||||
: t('assistant.card.packing.title');
|
||||
}
|
||||
|
||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
||||
/** The head band of an answer: the backpack, the title, how much is packed with its bar, and "All packed" once it is. */
|
||||
function PackingHead({ card }: { card: PackingCard }) {
|
||||
const { t } = useTranslation();
|
||||
const title = useTitle(card);
|
||||
@@ -73,50 +75,46 @@ function PackingHead({ card }: { card: PackingCard }) {
|
||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||
const first = card.groups[0]?.key;
|
||||
const color =
|
||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
||||
? packingColorOf(first.slot, source.category)
|
||||
: null;
|
||||
const done = card.progress !== null && total > 0 && packed === total;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</p>
|
||||
{caption && (
|
||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card.progress && total > 0 && packed === total && (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
<CardHeader
|
||||
icon={Backpack}
|
||||
tone={done ? 'success' : 'info'}
|
||||
title={title}
|
||||
count={source.type === 'template' ? source.itemCount : total}
|
||||
meta={caption ?? undefined}
|
||||
actions={
|
||||
done ? (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{card.progress && card.progress.total > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<PackedBar packed={card.progress.packed} total={card.progress.total} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card. */
|
||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||
const title = useTitle(card);
|
||||
const progress = card.progress;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<PackingHead card={card} />
|
||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<PackingHead card={card} />
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||
target={PACKING_TARGET}
|
||||
openKey="tours.packing.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'
|
||||
import { localToday } from '../../../Planner/today';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||
import {
|
||||
@@ -204,27 +205,30 @@ function TodoAnswer({ card }: { card: TodoCard }) {
|
||||
const title = t('assistant.card.todo.title');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
||||
{progress && progress.overdue > 0 && (
|
||||
<CardHeader
|
||||
icon={ListTodo}
|
||||
tone={progress && progress.overdue > 0 ? 'danger' : 'info'}
|
||||
title={title}
|
||||
meta={progress ? t('assistant.card.todo.openCount', { count: progress.open }) : undefined}
|
||||
actions={
|
||||
progress && progress.overdue > 0 ? (
|
||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey="assistant.card.todo.moreLists"
|
||||
target={TODO_TARGET}
|
||||
openKey="assistant.card.todo.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ChecklistRow({
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-x-2 gap-y-1">{aside}</span>}
|
||||
</div>
|
||||
{under}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { formatWeight } from '../../../Packing/packingListModel';
|
||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { packingColorOf } from './checklistModel';
|
||||
@@ -56,15 +55,31 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
|
||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||
*/
|
||||
/** A row's quantity and weight: quiet figures at its end, no frame each. */
|
||||
const QUIET = 'text-caption tabular-nums text-content-muted';
|
||||
|
||||
/** How many rows a group holds, quiet beside its name. */
|
||||
function GroupCount({ count }: { count: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.packing.items', { count })}
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const personal = t('assistant.card.packing.tierPersonal');
|
||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||
return (
|
||||
<>
|
||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
||||
{row.quantity > 1 && <span className={QUIET}>×{row.quantity}</span>}
|
||||
{partial && <span className={QUIET}>{`${row.packed}/${row.quantity}`}</span>}
|
||||
{row.weightGrams !== null && <span className={QUIET}>{formatWeight(row.weightGrams)}</span>}
|
||||
{row.tier === 'personal' && (
|
||||
<Tooltip label={personal}>
|
||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||
@@ -216,7 +231,7 @@ function BagHead({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
||||
const badge = inChange ? undefined : <GroupCount count={group.count} />;
|
||||
const lead = <BagSwatch bag={undefined} size="head" />;
|
||||
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
|
||||
}
|
||||
@@ -283,7 +298,7 @@ export function PackingGroup({
|
||||
<ChecklistHead
|
||||
color={packingColorOf(key.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
||||
badge={inChange ? undefined : <GroupCount count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The frame every answer card sits in, the same as the change card's: a
|
||||
* rounded card on the chat. A waiting change shows its frame dashed, a
|
||||
* rounded card on the chat whose head band (CardHeader) runs to its edges. A waiting change shows its frame dashed, a
|
||||
* removed one faded, a settled one (discarded, expired, undone) fainter
|
||||
* still. The chat card (a message in the trip chat) sits on the quieter
|
||||
* secondary surface.
|
||||
@@ -32,7 +32,7 @@ export default function CardFrame({
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
|
||||
className={`trek-assistant-enter overflow-hidden rounded-[16px] border px-3.5 pb-3 pt-3 shadow-card ${STATE[state]} ${
|
||||
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||
import type { Tone } from './assistantTones';
|
||||
import KindTile from './KindTile';
|
||||
|
||||
/**
|
||||
* The head of an answer card: its tile, what it is about (wrapping to two
|
||||
* lines at most, the whole title in its tooltip), how many it holds, one
|
||||
* quiet line of context under it, and its actions at the end.
|
||||
* The head band of an answer card, as the trip's dialogs and booking cards
|
||||
* wear it: a tinted band across the card's top, a raised white tile with the
|
||||
* icon of what the card is about in its tone, an eyebrow naming the kind,
|
||||
* the title, one quiet line under it, a count, and the card's actions at the
|
||||
* end. It reaches the card's edges, so a card that opens with it needs no
|
||||
* other head.
|
||||
*/
|
||||
|
||||
/** The icon's colour on the white tile, per tone. */
|
||||
const GLYPH: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
};
|
||||
|
||||
export default function CardHeader({
|
||||
icon,
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
eyebrow,
|
||||
count,
|
||||
meta,
|
||||
actions,
|
||||
tile,
|
||||
tint = NEUTRAL_TINT,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
tone: Tone;
|
||||
title: string;
|
||||
/** The kind above the title ("PACKING LIST"); none when the title says it. */
|
||||
eyebrow?: string;
|
||||
count?: number;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** In place of the icon tile: a drawing of what the card shows (a route's sketch). */
|
||||
tile?: ReactNode;
|
||||
tint?: string;
|
||||
/** Under the title row, on the band: a progress bar, a row of pills. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KindTile icon={icon} tone={tone} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<bdi title={title} className="line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && <WhiteBadge>{count}</WhiteBadge>}
|
||||
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-2.5 pt-2.5" style={{ background: tint }}>
|
||||
<div className={`flex gap-2.5 ${meta ? 'items-start' : 'items-center'}`}>
|
||||
{tile ?? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{eyebrow && (
|
||||
<div
|
||||
className="truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<bdi
|
||||
title={title}
|
||||
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
|
||||
style={fs(13.5, 'body')}
|
||||
>
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 font-geist text-caption text-content-muted">{meta}</div>}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ export function CardRow({
|
||||
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
||||
{facts}
|
||||
</span>
|
||||
{aside && <span className="flex-none text-caption tabular-nums text-content-secondary">{aside}</span>}
|
||||
{aside && (
|
||||
<span className="flex flex-none items-center gap-2 text-caption tabular-nums text-content-secondary">
|
||||
{aside}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const hover = onHover
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Eyebrow, Field } from '../../../Planner/bookings/bookingParts';
|
||||
|
||||
/**
|
||||
* The body of an answer card in the trip dialogs' look: sections under an
|
||||
* eyebrow of their own, figures as framed fields with their label over them
|
||||
* (the field every booking card is made of), and a foot bar for the card's
|
||||
* ways onward, set off by a line across the card.
|
||||
*/
|
||||
|
||||
/** One part of a card under its eyebrow, with room for something on the right of the label. */
|
||||
export function CardSection({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mt-3 first:mt-0">
|
||||
<div className="mb-1.5 flex min-h-4 items-center gap-2">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
{aside && <span className="ms-auto flex items-center gap-1">{aside}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card's figures, side by side in framed fields: as many columns as figures, at most four. */
|
||||
export function CardFields({ fields }: { fields: readonly { key: string; label: string; value: ReactNode }[] }) {
|
||||
if (fields.length === 0) return null;
|
||||
const columns = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'][Math.min(fields.length, 4) - 1];
|
||||
return (
|
||||
<div className={`grid gap-1.5 ${columns}`}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="min-w-0" data-field={field.key}>
|
||||
<Field label={field.label} tabular>
|
||||
{field.value}
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The foot of a card: its actions at the end, under a line that runs to the card's edges. */
|
||||
export function CardFoot({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="-mx-3.5 -mb-3 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-edge-faint bg-surface-secondary px-3.5 py-2.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Clock, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import type { LegVariant } from '../../../Tours/planner/legVariants';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
|
||||
/**
|
||||
* A way's length, climb and time as one quiet line of glyphs and values,
|
||||
* without a frame around each: what a choice of ways and a change row show
|
||||
* where framed fields would crowd the row.
|
||||
*/
|
||||
|
||||
/** One figure of a way: its glyph and its value, quiet, in a line of them. */
|
||||
function Figure({ name, icon, children }: { name: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-figure={name}>
|
||||
{icon}
|
||||
<span className="tabular-nums">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPH = { size: 11, strokeWidth: 2.2, 'aria-hidden': true, className: 'flex-none text-content-faint' } as const;
|
||||
|
||||
/** A way's length, climb and time in one quiet line. */
|
||||
export default function WayFigures({ way, unit }: { way: LegVariant; unit: DistanceUnit }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-caption text-content-secondary">
|
||||
<Figure name="distance" icon={<MoveHorizontal {...GLYPH} />}>
|
||||
{formatDistance(way.distanceM / 1000, unit)}
|
||||
</Figure>
|
||||
{way.gainM !== null && (
|
||||
<Figure name="ascent" icon={<TrendingUp {...GLYPH} />}>
|
||||
{formatElevation(way.gainM, unit)}
|
||||
</Figure>
|
||||
)}
|
||||
{way.minutes !== null && (
|
||||
<Figure name="time" icon={<Clock {...GLYPH} />}>
|
||||
{formatTourDuration(way.minutes)}
|
||||
</Figure>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { useTakeProposal } from './useTourRouteCard';
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function TourRouteActions({
|
||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||
return (
|
||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
||||
<CardFoot>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -92,10 +93,10 @@ export default function TourRouteActions({
|
||||
)}
|
||||
</div>
|
||||
{take.visible && caption && (
|
||||
<p className="m-0 text-end text-caption text-content-muted" role="status">
|
||||
<p className="m-0 basis-full text-end text-caption text-content-muted" role="status">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardFoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as framed fields, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
||||
expect(container.querySelector(`[data-field="${figure}"]`)).not.toBeNull();
|
||||
}
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||
@@ -159,7 +159,7 @@ describe('TourRouteCard', () => {
|
||||
|
||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||
renderCard(HIKE);
|
||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
||||
const hut = screen.getByRole('button', { name: /^Strausshütte, 09:40/ });
|
||||
fireEvent.mouseEnter(hut);
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||
fireEvent.mouseLeave(hut);
|
||||
@@ -171,11 +171,11 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
|
||||
renderCard(HIKE);
|
||||
const list = screen.getByRole('list', { name: 'Stops along the way' });
|
||||
// One row per stop; the lines between them are not rows.
|
||||
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(5);
|
||||
// One row per stop; the walks between them are hidden, read with the stop they lead to.
|
||||
expect(list.querySelectorAll('li:not([aria-hidden="true"])')).toHaveLength(5);
|
||||
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
|
||||
expect(screen.getAllByText(/from the last stop$/)).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(screen.getAllByRole('button', { name: /from the last stop$/ })).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('li[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(legs).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -234,18 +234,21 @@ describe('TourRouteCard', () => {
|
||||
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
|
||||
// The server applies the take; the thread shows it applied with the new tour's place.
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
const applied = heldTake(true);
|
||||
applied.parts.push({
|
||||
// The server sends the answer back with the take joined to it, now applied with the new tour's place.
|
||||
const applied = proposed();
|
||||
applied.parts.push(...heldTake(true).parts, {
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
|
||||
});
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), applied] } } });
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [applied] } } });
|
||||
});
|
||||
seed([proposed()]);
|
||||
useAssistantStore.setState({ decide });
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
|
||||
const joined = proposed();
|
||||
joined.parts.push(...heldTake(undefined).parts);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [joined] } } });
|
||||
return 'held';
|
||||
});
|
||||
renderCard(HIKE, { onOpen });
|
||||
@@ -374,7 +377,7 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start by 10:30')).toBeInTheDocument();
|
||||
|
||||
const { container } = renderCard({
|
||||
...HIKE,
|
||||
@@ -382,6 +385,6 @@ describe('TourRouteCard', () => {
|
||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||
});
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import TourRouteActions from './TourRouteActions';
|
||||
import TourRouteDay from './TourRouteDay';
|
||||
import TourRouteHead from './TourRouteHead';
|
||||
import TourRouteHead, { TourRouteFields } from './TourRouteHead';
|
||||
import { profileSamples } from './tourRouteModel';
|
||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||
import TourRouteProfile from './TourRouteProfile';
|
||||
@@ -40,19 +40,30 @@ export function TourRouteBody({
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
letter?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
return (
|
||||
<>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
<TourRouteFields card={card} />
|
||||
{/* A way drawn straight has neither heights nor the line to say so under: no section at all. */}
|
||||
{(samples.length >= 2 || card.routed) && (
|
||||
<CardSection label={t('tours.planner.inspector.elevation')}>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
</CardSection>
|
||||
)}
|
||||
{card.stops.length > 0 && (
|
||||
<CardSection label={t('assistant.card.tour.stops')}>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
</CardSection>
|
||||
)}
|
||||
<TourRouteDay card={card} />
|
||||
<TourRouteWarnings card={card} />
|
||||
<TourRouteProblem card={card} />
|
||||
@@ -63,12 +74,11 @@ export function TourRouteBody({
|
||||
|
||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const route = useTourRouteCard(toolCallId, card);
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={routeName(card, t)}>
|
||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
||||
<TourRouteHead card={card} onShow={route.showOnMap} />
|
||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||
</CardFrame>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||
@@ -93,9 +94,13 @@ export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
||||
)}
|
||||
{!schedule.start && schedule.latestStart && (
|
||||
<div className="mt-1 flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
<Tooltip label={t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStartShort', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{schedule.weather === 'climate' && (
|
||||
|
||||
@@ -4,12 +4,14 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, PILL } from '../../../shared/DialogShell';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFields } from '../parts/CardSection';
|
||||
import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
@@ -89,7 +91,7 @@ export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
/** The day a route is planned on, as a white pill on the band: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
@@ -98,7 +100,12 @@ function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const date = format.date(schedule.date);
|
||||
const text =
|
||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
||||
return (
|
||||
<span className={PILL} style={fs(11)}>
|
||||
<CalendarDays size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The route drawn small: a button that shows it on the map. */
|
||||
@@ -127,35 +134,51 @@ export function TourRouteSketch({
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteHead({
|
||||
card,
|
||||
sketchHeight,
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
sketchHeight: number;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
/** The route's figures as framed fields, each under its label: distance, climb, descent and time, as known. */
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
const label = t(FACT_LOOK[key].labelKey ?? 'tours.planner.inspector.duration');
|
||||
let text: string;
|
||||
if (key === 'time') text = formatTourDuration(value);
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of a route card: the route drawn small on a raised tile, what
|
||||
* kind of tour it is above its name, the label under it, and its grade and
|
||||
* the day it is planned on as pills on the band.
|
||||
*/
|
||||
export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; onShow: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const name = routeName(card, t);
|
||||
const tile = (
|
||||
<span className="w-12 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={48} onShow={onShow} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-[72px] flex-none">
|
||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
<TourGrade card={card} />
|
||||
</div>
|
||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
||||
<CardFacts>
|
||||
<TourRouteFigures card={card} />
|
||||
<CardHeader
|
||||
icon={tourDayRowIcon(card.activity)}
|
||||
tone="info"
|
||||
tile={tile}
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</CardFacts>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourGrade, TourRouteSketch } from './TourRouteHead';
|
||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
@@ -37,29 +38,33 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const previous = previousVariantOf(card);
|
||||
const notPlanned = card.problem?.why === 'not_planned';
|
||||
const grades = card.activity === 'hike' ? card.grades : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-16 flex-none">
|
||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<TourRouteFigures card={card} />
|
||||
<TourGrade card={card} />
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||
data-testid="tour-route-row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={36} onShow={showOnMap} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
</span>
|
||||
{notPlanned && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
{notPlanned && <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{!notPlanned && samples.length >= 2 && (
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||
if (samples.length < 2) {
|
||||
if (!card.routed) return null;
|
||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
return <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
}
|
||||
const phone = variant === 'fullscreen';
|
||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||
@@ -112,7 +112,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="rounded-[12px] border border-edge-faint bg-surface-secondary px-2 pb-1.5 pt-2">
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={color}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RouteProfileFocus,
|
||||
} from '../../../../utils/routeGeometry';
|
||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
@@ -18,12 +18,13 @@ import { FLAG_LOOK, stopName } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The stops of a route as a timeline in the order they lie along it: one row
|
||||
* per stop (the start and the end, the huts, summits and stations on the way
|
||||
* with their clock, what lies a little off the way quieter), joined by a line
|
||||
* that carries the walking time of the stretch between two stops, so a time
|
||||
* is never read as belonging to the stop beside it. Pointing at a chip or
|
||||
* tapping it moves the spot in focus there, on the profile and on the map
|
||||
* alike. Past eight the middle ones fold into a count that names them.
|
||||
* per stop on a line down the card (the start and the end filled, the huts,
|
||||
* summits and stations on the way with their glyph, what lies a little off
|
||||
* the way quieter), its name, how far off the way or how long the stay under
|
||||
* it, and its clock at the end; the walk between two stops sits on the line
|
||||
* between their rows, so a time is never read as a stop's own. Pointing at a
|
||||
* row or tapping it moves the spot in focus there, on the profile and on the
|
||||
* map alike. Past eight the middle ones fold into a row that names them.
|
||||
*/
|
||||
|
||||
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
||||
@@ -46,12 +47,31 @@ function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSa
|
||||
);
|
||||
}
|
||||
|
||||
function StopChip({
|
||||
/** A stop's mark on the line: filled at the start and the end, a ring with its glyph on the way, smaller off it. */
|
||||
function Marker({ stop }: { stop: TourRouteStop }) {
|
||||
const end = stop.role === 'start' || stop.role === 'end';
|
||||
const along = stop.role === 'along';
|
||||
const glyph = createElement(iconOf(stop), { size: along ? 9 : 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
let look = 'h-5 w-5 bg-surface-card text-content-muted ring-1 ring-inset ring-edge';
|
||||
if (end) look = 'h-5 w-5 bg-accent text-accent-text';
|
||||
else if (along) look = 'h-4 w-4 bg-surface-secondary text-content-faint ring-1 ring-inset ring-edge-faint';
|
||||
return (
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className={`grid place-items-center rounded-full ${look}`}>{glyph}</span>
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StopRow({
|
||||
stop,
|
||||
leg,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
stop: TourRouteStop;
|
||||
/** The walk from the last stop with a time to this one, read with it. */
|
||||
leg: number | null;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
@@ -65,44 +85,45 @@ function StopChip({
|
||||
: null;
|
||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
||||
const glyph = createElement(iconOf(stop), {
|
||||
size: 10,
|
||||
strokeWidth: 2.2,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-none text-content-faint',
|
||||
});
|
||||
const body = (
|
||||
<>
|
||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
||||
{off && <span className="text-content-faint">{off}</span>}
|
||||
</>
|
||||
);
|
||||
const walked = leg !== null ? t('assistant.card.tour.legTime', { time: format.minutes(leg) }) : null;
|
||||
const label = [name, clock, off, stay, walked].filter(Boolean).join(', ');
|
||||
const point = () => onFocus(focusOf(stop, samples));
|
||||
const under = [off, stay].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={() => onFocus(focusOf(stop, samples))}
|
||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
<li className="flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={point}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={point}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={point}
|
||||
className="-mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Marker stop={stop} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<bdi
|
||||
className={`block truncate leading-tight ${along ? 'text-caption text-content-secondary' : 'text-body font-medium text-content'}`}
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{(under.length > 0 || stop.flag) && (
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-content-faint">
|
||||
{under.map((part) => (
|
||||
<span key={part}>{part}</span>
|
||||
))}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{clock && <span className="flex-none text-caption tabular-nums text-content-muted">{clock}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,38 +133,36 @@ function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number
|
||||
return to.minutes - from.minutes;
|
||||
}
|
||||
|
||||
/** The line between two rows, with the stretch's walking time on it. */
|
||||
/** The walk between two rows, beside the line: read with the row it leads to, so hidden here. */
|
||||
function Leg({ minutes }: { minutes: number | null }) {
|
||||
const format = useCardFormat();
|
||||
if (minutes === null) return <li aria-hidden="true" className="h-1.5" />;
|
||||
return (
|
||||
// Read with the stop it leads to; a list holds only its rows.
|
||||
<span aria-hidden="true" className="flex min-h-[14px] items-stretch gap-2">
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span className="w-px bg-edge" />
|
||||
<li aria-hidden="true" className="flex items-center py-0.5 ps-[30px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-caption tabular-nums text-content-muted">
|
||||
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
{format.minutes(minutes)}
|
||||
</span>
|
||||
{minutes !== null && (
|
||||
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of the timeline: its dot on the line, then the stop, read after the walk that leads to it. */
|
||||
function Row({ end, leg, children }: { end: boolean; leg?: number | null; children: ReactNode }) {
|
||||
/** The middle stops folded into one row past eight, naming them in its tooltip. */
|
||||
function FoldedRow({ stops }: { stops: readonly TourRouteStop[] }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const names = stops.map((stop) => stopName(stop, t)).join(', ');
|
||||
return (
|
||||
<span role="listitem" className="flex items-center gap-2">
|
||||
{leg != null && (
|
||||
<span className="sr-only">{t('assistant.card.tour.legTime', { time: format.minutes(leg) })}</span>
|
||||
)}
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${end ? 'bg-content-muted' : 'border border-content-faint bg-surface-card'}`}
|
||||
/>
|
||||
<li className="flex items-center gap-2.5 py-1">
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className="h-2 w-2 rounded-full border border-dashed border-content-faint bg-surface-card" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
|
||||
</span>
|
||||
<Tooltip label={names}>
|
||||
<span className="text-caption text-content-muted underline decoration-dotted underline-offset-2">
|
||||
{t('assistant.card.tour.stopsMore', { count: stops.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,42 +178,30 @@ export default function TourRouteStops({
|
||||
const { t } = useTranslation();
|
||||
const { head, folded, tail } = chipStops(card);
|
||||
if (head.length === 0) return null;
|
||||
const names = folded.map((stop) => stopName(stop, t)).join(', ');
|
||||
// The time is walked from the last stop that has one: a place beside the way has none.
|
||||
let walked: TourRouteStop | undefined;
|
||||
const rows: ReactNode[] = [];
|
||||
const pushStop = (stop: TourRouteStop, key: string) => {
|
||||
const leg = legMinutes(walked, stop);
|
||||
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
|
||||
rows.push(
|
||||
<Row key={key} end={stop.role === 'start' || stop.role === 'end'} leg={leg}>
|
||||
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
|
||||
</Row>
|
||||
);
|
||||
rows.push(<StopRow key={key} stop={stop} leg={leg} samples={samples} onFocus={onFocus} />);
|
||||
if (stop.minutes !== null) walked = stop;
|
||||
};
|
||||
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
|
||||
if (folded.length > 0) {
|
||||
rows.push(<Leg key="f-leg" minutes={null} />);
|
||||
rows.push(
|
||||
<Row key="f" end={false}>
|
||||
<Tooltip label={names}>
|
||||
<span className="inline-flex">
|
||||
<WhiteBadge>
|
||||
{t('assistant.card.tour.stopsMore', { count: folded.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Row>
|
||||
);
|
||||
rows.push(<FoldedRow key="f" stops={folded} />);
|
||||
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
|
||||
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
|
||||
}
|
||||
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
|
||||
return (
|
||||
<div className="mt-2 flex flex-col" role="list" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
<div className="relative">
|
||||
{/* The line down the timeline, from the first mark to the last, under the marks. */}
|
||||
<span aria-hidden="true" className="absolute bottom-3 start-[9.5px] top-3 w-px bg-edge" />
|
||||
<ul className="relative m-0 flex list-none flex-col p-0" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,128 @@
|
||||
import type { TourRouteCard as Route } from '@trek/shared';
|
||||
import { Route as RouteIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
||||
import { type LegVariant, variantDelta } from '../../../Tours/planner/legVariants';
|
||||
import { VariantMark } from '../../../Tours/planner/mapCardParts';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
||||
/** A signed difference: a plus for more, a minus sign for less, nothing for none worth printing. */
|
||||
function signed(value: number, format: (absolute: number) => string): string | null {
|
||||
const text = format(Math.abs(value));
|
||||
if (Math.abs(value) < 1e-9 || /^0\s/.test(text)) return null;
|
||||
return `${value > 0 ? '+' : '−'}${text}`;
|
||||
}
|
||||
|
||||
/** How a way differs from the chosen one, as one faint line: "Compared with way A: −4.3 km −178 m −1 h 33 min". */
|
||||
function WayDelta({
|
||||
way,
|
||||
chosen,
|
||||
letter,
|
||||
unit,
|
||||
}: {
|
||||
way: LegVariant;
|
||||
chosen: LegVariant;
|
||||
letter: string;
|
||||
unit: DistanceUnit;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const delta = variantDelta(way, chosen);
|
||||
const parts = [
|
||||
signed(delta.distanceM / 1000, (km) => formatDistance(km, unit)),
|
||||
delta.gainM === null ? null : signed(delta.gainM, (m) => formatElevation(m, unit)),
|
||||
delta.minutes === null ? null : signed(delta.minutes, formatTourDuration),
|
||||
].filter((part): part is string => part !== null);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-2 text-caption text-content-faint"
|
||||
data-testid="leg-variant-delta"
|
||||
>
|
||||
<span>{t('assistant.card.tour.deltaTo', { letter })}</span>
|
||||
{parts.map((part) => (
|
||||
<span key={part} className="tabular-nums text-content-muted">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One way to choose: its letter in the colour it is drawn in, its name and grade, its figures, how it differs. */
|
||||
function WayOption({
|
||||
entry,
|
||||
index,
|
||||
chosen,
|
||||
chosenWay,
|
||||
chosenLetter,
|
||||
unit,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: {
|
||||
entry: RouteEntry;
|
||||
index: number;
|
||||
chosen: boolean;
|
||||
chosenWay: LegVariant;
|
||||
chosenLetter: string;
|
||||
unit: DistanceUnit;
|
||||
onSelect: () => void;
|
||||
onHover: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
const name = entry.card.label ?? t('tours.variants.option', { letter });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chosen}
|
||||
aria-label={name}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
onFocus={() => onHover(true)}
|
||||
onBlur={() => onHover(false)}
|
||||
className={`flex w-full items-start gap-2.5 rounded-[12px] border px-2.5 py-2 text-start transition-colors ${
|
||||
chosen
|
||||
? 'border-accent bg-surface-card shadow-sm'
|
||||
: 'border-edge-faint bg-surface-secondary hover:border-edge hover:bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
<VariantMark variant={way} chosen={chosen} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<bdi className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">{name}</bdi>
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ways of one answer with the chosen one known: the band, the choices, then the chosen way's body. */
|
||||
function WaysCard({
|
||||
entries,
|
||||
chosen,
|
||||
@@ -44,57 +153,49 @@ function WaysCard({
|
||||
[entries, chosenIndex]
|
||||
);
|
||||
|
||||
const current = legVariantOf(chosen.card, chosenKey);
|
||||
const chosenWay = legVariantOf(chosen.card, chosenKey);
|
||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||
let columns = '';
|
||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||
const card: Route = chosen.card;
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<div {...route.handlers} className={`rounded-[16px] ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={title}>
|
||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => {
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
return (
|
||||
<WayChoice
|
||||
<CardHeader
|
||||
icon={RouteIcon}
|
||||
tone="info"
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={routeName(card, t)}
|
||||
meta={title}
|
||||
/>
|
||||
<CardSection label={t('assistant.card.tour.waysPick')}>
|
||||
<div role="radiogroup" aria-label={title} className={`grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => (
|
||||
<WayOption
|
||||
key={entry.toolCallId}
|
||||
way={way}
|
||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
||||
entry={entry}
|
||||
index={index}
|
||||
chosen={index === chosenIndex}
|
||||
aside={
|
||||
<>
|
||||
{index !== chosenIndex && (
|
||||
<VariantDelta
|
||||
variant={way}
|
||||
current={current}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
||||
/>
|
||||
)}
|
||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
||||
</>
|
||||
}
|
||||
chosenWay={chosenWay}
|
||||
chosenLetter={chosenKey.toUpperCase()}
|
||||
unit={unit}
|
||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||
onHover={(on) => hover(entry.toolCallId, on)}
|
||||
>
|
||||
<VariantFigures variant={way} unit={unit} t={t} />
|
||||
</WayChoice>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
<div className="mt-3">
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -102,12 +203,12 @@ function WaysCard({
|
||||
|
||||
/**
|
||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
||||
* its own words, how it differs from the chosen one and its hardest grade,
|
||||
* its figures below. One profile holds them all, the chosen way filled and
|
||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
||||
* way lights its line on the map.
|
||||
* section's ways: a band naming the route, each way a choice with its letter
|
||||
* in the colour it is drawn in, its name and hardest grade, its figures and
|
||||
* how it differs from the chosen one; one profile holds them all, the chosen
|
||||
* way filled and the others thin on the same axes; under it the chosen way's
|
||||
* figures, stops, day and warnings, and "Plan way B". A choice is kept per
|
||||
* answer, and pointing at a way lights its line on the map.
|
||||
*/
|
||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||
const { tripId } = useAssistantView();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { Route } from 'lucide-react';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -86,6 +86,15 @@ export const TOUR_LOOK = {
|
||||
verbs: {
|
||||
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
||||
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
||||
markVisited: { Icon: CircleCheck, tone: 'success', labelKey: 'assistant.change.verb.markVisited' },
|
||||
markSkipped: { Icon: CircleSlash, tone: 'info', labelKey: 'assistant.change.verb.markSkipped' },
|
||||
markPlanned: { Icon: Circle, tone: 'info', labelKey: 'assistant.change.verb.markPlanned' },
|
||||
assignTrack: { Icon: Footprints, tone: 'success', labelKey: 'assistant.change.verb.assignTrack' },
|
||||
unassignTrack: { Icon: Footprints, tone: 'danger', labelKey: 'assistant.change.verb.unassignTrack' },
|
||||
excludeTour: { Icon: ChartNoAxesColumn, tone: 'info', labelKey: 'assistant.change.verb.excludeTour' },
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -96,6 +105,7 @@ export const TOUR_LOOK = {
|
||||
list_tours: { stepKey: 'assistant.step.readingTours' },
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -67,6 +67,9 @@ describe('takeInThread', () => {
|
||||
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
||||
// The server joins a take to the answer that drew the proposal: found there too.
|
||||
const joined = { ...proposal, parts: [...proposal.parts, ...take(other, {}, 'take_proposal', 'call_1').parts] };
|
||||
expect(takeInThread([joined], 'call_1', HIKE)).toBe('waiting');
|
||||
// The same way taken from another proposal leaves this one free.
|
||||
expect(
|
||||
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
||||
|
||||
@@ -45,7 +45,8 @@ export function takeOfProposal(
|
||||
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
|
||||
const from = proposalIndex(messages, toolCallId);
|
||||
if (from < 0) return null;
|
||||
for (const message of messages.slice(from + 1)) {
|
||||
// The server joins a take to the answer that drew its proposal, so that answer is searched as well as the later ones.
|
||||
for (const message of messages.slice(from)) {
|
||||
const open = new Map<string, 'waiting' | 'applied'>();
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
||||
|
||||
@@ -14,6 +14,6 @@ import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
* file.
|
||||
*/
|
||||
export const TOUR_VIEWS = {
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow, wideBody: true },
|
||||
tourList: { Block: TourListCard },
|
||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { Palmtree } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
import VacayBalance from './VacayBalance';
|
||||
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
||||
@@ -26,7 +30,6 @@ const SQUARE = 'size-2 flex-none rounded-sm';
|
||||
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
||||
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
||||
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
||||
const FOOT = 'mt-1 flex justify-end';
|
||||
|
||||
interface LegendItem {
|
||||
key: string;
|
||||
@@ -79,13 +82,14 @@ export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[
|
||||
const first = own[0] ?? null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.vacay.label')}>
|
||||
<CardHeader icon={Palmtree} tone="info" title={t('assistant.card.vacay.label')} />
|
||||
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
||||
<Legend view={view} />
|
||||
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
||||
<VacayBalance lines={lines} view={view} mode="read" />
|
||||
<div className={FOOT}>
|
||||
<CardFoot>
|
||||
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Assignment } from '../../types';
|
||||
import { dayLabel } from '../../utils/dayLabel';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantScope, useAssistantView } from './assistantViewContext';
|
||||
import { useCardPoint } from './cards/parts/useCardPoint';
|
||||
|
||||
/** A day a found place can be suggested for, named the way the day list names it. */
|
||||
interface StripDay {
|
||||
id: number;
|
||||
/** Its place in the trip, 1 for the first day: "+ Day 3". */
|
||||
n: number;
|
||||
label: string;
|
||||
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
|
||||
weekday: string | null;
|
||||
}
|
||||
|
||||
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
|
||||
function weekdayOf(date: string | null | undefined, locale: string): string | null {
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** The trip category a found place most likely belongs in, for its line and its icon. */
|
||||
interface StripCategory {
|
||||
name: string;
|
||||
@@ -54,20 +34,17 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
|
||||
|
||||
/**
|
||||
* What the strip of found places needs, the same for the window and the
|
||||
* phone: the trip's days to suggest a place for, the day in focus, how far a
|
||||
* place is from that day's last stop, its likely category, whether a
|
||||
* suggestion can be made now and why not, and the map's part (the pin while
|
||||
* the pointer rests on a card, the camera on a click).
|
||||
* phone: the day in focus, how far a place is from that day's last stop, its
|
||||
* likely category, whether a place can be added, and the map's part (the pin
|
||||
* while the pointer rests on a card, the camera on a click).
|
||||
*
|
||||
* Suggesting writes nothing: the server holds the place as a change card to
|
||||
* decide like any other. It waits while another change is undecided, because
|
||||
* one decision answers all open cards.
|
||||
* Adding opens the planner's own place form, filled in with the place and on
|
||||
* the day in focus: the traveller sees everything about it and saves it as
|
||||
* any other place, so nothing waits for the chat's open decisions.
|
||||
*/
|
||||
export function useAssistantPlaceStrip() {
|
||||
const { t, locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { tripId, onRevealMap } = useAssistantView();
|
||||
const scope = useAssistantScope();
|
||||
const { offline } = useNetworkMode();
|
||||
const can = useCanDo();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const { trip, days, assignments, categories } = useTripStore(
|
||||
@@ -81,44 +58,15 @@ export function useAssistantPlaceStrip() {
|
||||
};
|
||||
})
|
||||
);
|
||||
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
|
||||
useShallow((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return {
|
||||
focusDayId: chat.focus?.dayId ?? null,
|
||||
isRunning: chat.isRunning,
|
||||
threadId: chat.threadId,
|
||||
waiting: hasUndecided(chat.messages),
|
||||
};
|
||||
})
|
||||
);
|
||||
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
|
||||
const point = useCardPoint();
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
|
||||
const stripDays = useMemo(
|
||||
(): StripDay[] =>
|
||||
(days ?? []).map((day, index) => ({
|
||||
id: day.id,
|
||||
n: index + 1,
|
||||
label: dayLabel(day, index, t, locale),
|
||||
weekday: weekdayOf(day.date, locale),
|
||||
})),
|
||||
[days, t, locale]
|
||||
);
|
||||
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
|
||||
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
|
||||
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
|
||||
|
||||
/**
|
||||
* Whether "+ Day" shows at all: a trip's chat (a journey's has no days), the
|
||||
* planner holds the trip and the user may add to it.
|
||||
*/
|
||||
const canSuggest = scope.kind === 'trip' && !!trip && can('place_edit', trip) && stripDays.length > 0;
|
||||
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
|
||||
let blockedKey: string | null = null;
|
||||
if (offline) blockedKey = 'assistant.error.offline';
|
||||
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
|
||||
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
|
||||
/** Whether "Add to trip" shows at all: a trip's chat (a journey's has no days), the planner holds the trip and the user may add to it. */
|
||||
const canAdd = scope.kind === 'trip' && !!trip && can('place_edit', trip);
|
||||
|
||||
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
|
||||
const id = categoryIdForKind(place.kind, categories);
|
||||
@@ -130,14 +78,11 @@ export function useAssistantPlaceStrip() {
|
||||
const distanceOf = (place: AssistantFoundPlace): string | null =>
|
||||
from ? formatDistance(haversineKm(from, place), unit) : null;
|
||||
|
||||
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
|
||||
if (disabled) return;
|
||||
setAdding(place.ref);
|
||||
try {
|
||||
await addFoundPlace(tripId, place.ref, dayId);
|
||||
} finally {
|
||||
setAdding(null);
|
||||
}
|
||||
/** Opens the place form with it; on the phone the chat steps aside first. */
|
||||
const open = (place: AssistantFoundPlace) => {
|
||||
if (!canAdd || !trip) return;
|
||||
onRevealMap?.();
|
||||
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
|
||||
};
|
||||
|
||||
/** Shows the place on the map; on the phone the chat steps aside first. */
|
||||
@@ -151,17 +96,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return {
|
||||
days: stripDays,
|
||||
focusDay,
|
||||
canSuggest,
|
||||
disabled,
|
||||
blockedKey,
|
||||
adding,
|
||||
categoryOf,
|
||||
distanceOf,
|
||||
suggest,
|
||||
show,
|
||||
hover,
|
||||
};
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function placeEditForm(
|
||||
}
|
||||
|
||||
/** A position the form opens on: a POI tapped on the map, or a right-click on it. */
|
||||
export interface PlacePrefill {
|
||||
export type PlacePrefill = {
|
||||
lat: number
|
||||
lng: number
|
||||
name?: string
|
||||
@@ -136,6 +136,8 @@ export interface PlacePrefill {
|
||||
website?: string
|
||||
phone?: string
|
||||
osm_id?: string
|
||||
/** A place Google knows, such as one the assistant found: the details column reads it by this id. */
|
||||
google_place_id?: string
|
||||
}
|
||||
|
||||
/** A blank form at a prefilled position, with what the POI already knew. */
|
||||
@@ -149,6 +151,7 @@ export function prefillForm(prefill: PlacePrefill, extra: Partial<PlaceFormData>
|
||||
website: prefill.website || '',
|
||||
phone: prefill.phone || '',
|
||||
osm_id: prefill.osm_id,
|
||||
google_place_id: prefill.google_place_id,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export function openingDetailsSelection(
|
||||
}
|
||||
}
|
||||
if (prefill) {
|
||||
return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
return { placeId: prefill.google_place_id || prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||
prefillForm, type PlaceFormData,
|
||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
||||
} from './PlaceFormModal.helpers'
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
@@ -64,7 +64,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null
|
||||
prefillCoords?: (PlacePrefill & { stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string }) | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { folioInk } from './folioColour'
|
||||
import { isNumbered } from './bookSheets'
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PageNumbers({
|
||||
textAlign: at.align,
|
||||
fontFamily: fontStack(cfg.font),
|
||||
fontSize: `${size}pt`,
|
||||
fontWeight: 500,
|
||||
fontWeight: nearestWeight(cfg.font, 500),
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
color: ink.color,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
|
||||
} from '@trek/shared'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
@@ -417,7 +417,7 @@ export function ElementView({
|
||||
// the conversion exactly.
|
||||
fontSize: `${el.size}pt`,
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontWeight: nearestWeight(el.font, el.weight),
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
|
||||
@@ -22,6 +22,8 @@ import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import StudioAssistantPanel, { StudioAssistantColumn, StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/** The assistant's surface is loaded lazily, which can outlast findBy's default second on CI. */
|
||||
const LAZY = { timeout: 10_000 };
|
||||
const JOURNEY = 12;
|
||||
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
@@ -75,7 +77,8 @@ describe('StudioAssistantPanel', () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
const notice = screen.getByRole('note');
|
||||
expect(notice.textContent).toContain('Only that page goes to Anthropic');
|
||||
|
||||
@@ -97,7 +100,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
useAssistantStore.setState({ status: { ...STATUS, studioImagesAcked: true } });
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -144,7 +148,8 @@ describe('StudioAssistantPanel', () => {
|
||||
it('FE-STUDIO-ASTPANEL-005: the journey chat offers no trip notes, which only a trip turn reads', async () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Recent chats' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Trip notes' })).toBeNull();
|
||||
});
|
||||
@@ -175,7 +180,8 @@ describe('StudioAssistantPanel', () => {
|
||||
});
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Show it pages' })).toBeNull();
|
||||
});
|
||||
@@ -207,7 +213,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
render(<StudioAssistantColumn assistant={assistant()} />);
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Journey' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { bookElementId as elementId } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, nearestWeight } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -325,7 +325,7 @@ export function StudioCanvas({
|
||||
// The document is in pt; the overlay is on screen at the same zoom.
|
||||
fontSize: `${el.size * (96 / 72) * zoom}px`,
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontWeight: nearestWeight(el.font, el.weight),
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { BOOK_FONT_ORDER } from './bookFonts';
|
||||
import { matchFonts, pickFont } from './fontPicker';
|
||||
import { StudioFontPicker } from './StudioFontPicker';
|
||||
|
||||
/**
|
||||
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
|
||||
* a list grouped by kind with every family in its own face, the book's own
|
||||
* families first, a search, and a weight that lands on one the family has.
|
||||
*/
|
||||
|
||||
const t = (k: string, params?: Record<string, string | number>) =>
|
||||
params ? `${k} ${Object.values(params).join(' ')}` : k;
|
||||
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
|
||||
|
||||
const frame = { x: 10, y: 10, w: 80, h: 20 };
|
||||
function bookIn(...fonts: string[]): BookDocument {
|
||||
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
|
||||
const elements = fonts.map(
|
||||
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
|
||||
);
|
||||
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
|
||||
return {
|
||||
...doc,
|
||||
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
|
||||
spreads: [{ ...cover, elements }, ...rest],
|
||||
};
|
||||
}
|
||||
|
||||
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.setState({ doc: null });
|
||||
});
|
||||
|
||||
describe('choosing a family', () => {
|
||||
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
|
||||
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
|
||||
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
|
||||
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
|
||||
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
|
||||
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
|
||||
expect(matchFonts('mono', groupName)).toEqual(['mono']);
|
||||
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
|
||||
expect(matchFonts('nothing like it', groupName)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the picker', () => {
|
||||
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
|
||||
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
|
||||
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
|
||||
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
|
||||
expect(current.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
|
||||
|
||||
open();
|
||||
expect(current.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
|
||||
const groups = screen.getAllByRole('group');
|
||||
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
|
||||
const used = within(groups[0] as HTMLElement).getAllByRole('option');
|
||||
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
|
||||
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
|
||||
// Every family, once per group it is in, each set in its own face.
|
||||
const rows = screen.getAllByRole('option');
|
||||
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
|
||||
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
|
||||
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
|
||||
expect(anton?.textContent).toContain('400');
|
||||
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
|
||||
open();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
|
||||
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
|
||||
fireEvent.click(screen.getByText('Anton'));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
|
||||
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
|
||||
open();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
|
||||
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
|
||||
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
|
||||
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
// A second click on the open button closes it too.
|
||||
open();
|
||||
open();
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
|
||||
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
|
||||
import { Check, ChevronDown, Search, X } from 'lucide-react';
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-fonts.css';
|
||||
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
|
||||
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
|
||||
|
||||
/**
|
||||
* The typeface picker, shared by the text inspector and the travel inspector.
|
||||
*
|
||||
* Thirty four families do not fit as a wall of buttons the way seven did, and
|
||||
* a plain list of names says nothing about how a face looks, which is the only
|
||||
* thing anyone is choosing between. So the current family sits on one line,
|
||||
* set in itself, and opens a list grouped the way people look for type
|
||||
* (a sans, a serif, something for a poster, a script to lay over a photo)
|
||||
* where every family is again set in its own face. The families the book is
|
||||
* already set in come first, because the second text of a book is usually set
|
||||
* like the first.
|
||||
*
|
||||
* The list opens inside the inspector rather than floating over the page: the
|
||||
* inspector scrolls, and a popover pinned to a control that scrolls away is a
|
||||
* popover pointing at nothing.
|
||||
*/
|
||||
|
||||
function FontRow({
|
||||
id,
|
||||
on,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
id: BookFontId;
|
||||
on: boolean;
|
||||
weight: number;
|
||||
onPick: () => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
const face = BOOK_FONTS[id];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
|
||||
onClick={onPick}
|
||||
title={face.name}
|
||||
>
|
||||
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
|
||||
{face.name}
|
||||
</span>
|
||||
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
|
||||
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FontGroup({
|
||||
label,
|
||||
ids,
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
label: string;
|
||||
ids: BookFontId[];
|
||||
font: string;
|
||||
weight: number;
|
||||
onPick: (id: BookFontId) => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
if (!ids.length) return null;
|
||||
return (
|
||||
<div className="st-fontpick-group" role="group" aria-label={label}>
|
||||
<div className="st-fontpick-label">{label}</div>
|
||||
{ids.map((id) => (
|
||||
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The families the open book is set in, read from the document the Studio holds. */
|
||||
function useFontsInBook(): BookFontId[] {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
|
||||
}
|
||||
|
||||
export function StudioFontPicker({
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
font: string;
|
||||
weight: number;
|
||||
onPick: (patch: FontPick) => void;
|
||||
t: FontT;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const listId = useId();
|
||||
const inBook = useFontsInBook();
|
||||
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
|
||||
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
|
||||
const found = matchFonts(query, groupName);
|
||||
|
||||
// Opened on purpose, so typing goes straight to the search.
|
||||
useEffect(() => {
|
||||
if (open) search.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
trigger.current?.focus();
|
||||
};
|
||||
const choose = (id: BookFontId) => {
|
||||
onPick(pickFont(id, weight));
|
||||
close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className="st-fontpick-current"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
>
|
||||
<span
|
||||
className="st-fontpick-face"
|
||||
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
|
||||
>
|
||||
{current.name}
|
||||
</span>
|
||||
<span className="st-fontpick-span">{groupName(current.category)}</span>
|
||||
<ChevronDown size={14} className="st-fontpick-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="st-fontpick-panel">
|
||||
<div className="st-search st-fontpick-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
ref={search}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.fonts.search')}
|
||||
aria-label={t('journey.studio.fonts.search')}
|
||||
aria-controls={listId}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
|
||||
{!query && (
|
||||
<FontGroup
|
||||
label={t('journey.studio.fonts.used')}
|
||||
ids={inBook}
|
||||
font={font}
|
||||
weight={weight}
|
||||
onPick={choose}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{BOOK_FONT_CATEGORIES.map((category) => (
|
||||
<FontGroup
|
||||
key={category}
|
||||
label={groupName(category)}
|
||||
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
|
||||
font={font}
|
||||
weight={weight}
|
||||
onPick={choose}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { Swatches } from './StudioSwatches'
|
||||
import { formatBookCoords } from '@trek/shared'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
import { italicTitle } from './fontPicker'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -323,7 +325,7 @@ export function StudioInspector({
|
||||
nothing about a typeface — and grouped, because "which serif"
|
||||
is a different question from "serif or sans".
|
||||
*/}
|
||||
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
|
||||
<Line label={t('journey.studio.align')}>
|
||||
@@ -344,7 +346,7 @@ export function StudioInspector({
|
||||
<button type="button"
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={t('journey.studio.italic')}
|
||||
title={italicTitle(el.font, el.weight, t)}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,8 @@ export default function StudioShell() {
|
||||
const [bookView, setBookView] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
|
||||
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
|
||||
useWarmBookFaces()
|
||||
|
||||
if (s.isMobile) {
|
||||
return createPortal(
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
import { StudioFontPicker } from './StudioFontPicker'
|
||||
import { WeightLine } from './StudioTypeControls'
|
||||
import { sourceIdOf, useMapSources } from './mapSources'
|
||||
import { printDpi, tileView } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
@@ -82,7 +83,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
|
||||
* wrong typeface and no way to fix it.
|
||||
*/}
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={false}>
|
||||
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
|
||||
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<WeightLine
|
||||
font={el.font}
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { BOOK_FONT_ORDER } from './bookFonts';
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls';
|
||||
import { italicTitle } from './fontPicker';
|
||||
import { WeightLine } from './StudioTypeControls';
|
||||
|
||||
const t = (k: string) => k;
|
||||
|
||||
describe('FontButtons', () => {
|
||||
it('offers every bundled family in the picker order and marks the current one', () => {
|
||||
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
|
||||
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
|
||||
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
|
||||
});
|
||||
|
||||
it('keeps the weight when the new family ships it', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
|
||||
});
|
||||
|
||||
it('moves the weight to one the new family really has', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
|
||||
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeightLine', () => {
|
||||
it('greys the weights the family does not ship and says why', () => {
|
||||
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
|
||||
it('steps through only the weights the family has and says the drawn one by name and number', () => {
|
||||
render(<WeightLine font="oswald" weight={700} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
|
||||
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
|
||||
expect(bold.disabled).toBe(true);
|
||||
expect(bold.title).toBe('journey.studio.weightMissing');
|
||||
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
|
||||
expect(regular.disabled).toBe(false);
|
||||
expect(regular.getAttribute('aria-checked')).toBe('true');
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement;
|
||||
// Oswald ships 200 to 700: six stops, the last one drawn.
|
||||
expect([slider.min, slider.max, slider.value]).toEqual(['0', '5', '5']);
|
||||
expect(slider.getAttribute('aria-valuetext')).toBe('journey.studio.weight.700 700');
|
||||
expect(screen.getByText('journey.studio.weight.700 700')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hands the picked weight back', () => {
|
||||
it('hands back the weight at the stop picked', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: '600' }));
|
||||
expect(onPick).toHaveBeenCalledWith(600);
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } });
|
||||
expect(onPick).toHaveBeenCalledWith(100);
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '8' } });
|
||||
expect(onPick).toHaveBeenLastCalledWith(900);
|
||||
});
|
||||
|
||||
it('shows the one weight of a family that has one, with nothing to drag, and the weight it really draws', () => {
|
||||
render(<WeightLine font="bebas" weight={700} onPick={vi.fn()} t={t} />);
|
||||
expect(screen.queryByRole('slider')).toBeNull();
|
||||
expect(screen.getByText('journey.studio.weightOnly')).toBeTruthy();
|
||||
expect(screen.getByText('journey.studio.weight.400 400')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reads a family this build does not know as the default one', () => {
|
||||
render(<WeightLine font="gone" weight={500} onPick={vi.fn()} t={t} />);
|
||||
expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('italicTitle', () => {
|
||||
it('says when the browser slants a family that has no italic of its own', () => {
|
||||
expect(italicTitle('garamond', 400, t)).toBe('journey.studio.italic');
|
||||
expect(italicTitle('sans', 400, t)).toBe('journey.studio.italicSlanted');
|
||||
expect(italicTitle('playfair', 300, t)).toBe('journey.studio.italic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +1,27 @@
|
||||
import type { BookTextElement } from '@trek/shared';
|
||||
import type { BookFontId } from './bookFonts';
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
|
||||
import { Choice, Line } from './StudioControls';
|
||||
import type { BookWeight } from '@trek/shared';
|
||||
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
|
||||
import { Line } from './StudioControls';
|
||||
|
||||
/**
|
||||
* The typeface buttons and the weight line, shared by the text inspector and
|
||||
* the travel inspector so both set type the same way.
|
||||
* The weight line, shared by the text inspector and the travel inspector so
|
||||
* both set type the same way. The typeface itself is picked in
|
||||
* StudioFontPicker.tsx, the italic's title comes from fontPicker.ts.
|
||||
*/
|
||||
|
||||
/** The weight set the book schema allows, taken from it so there is one list. */
|
||||
type BookWeight = BookTextElement['weight'];
|
||||
type T = (key: string) => string;
|
||||
|
||||
const WEIGHTS = [400, 500, 600, 700] as const;
|
||||
/** The weights a family ships, lightest first; Poppins' for a family this build does not know. */
|
||||
const weightsOf = (font: string) => (BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans).weights;
|
||||
|
||||
/** One button per bundled family, each set in its own face. */
|
||||
export function FontButtons({
|
||||
font,
|
||||
weight,
|
||||
onPick,
|
||||
}: {
|
||||
font: string;
|
||||
weight: BookWeight;
|
||||
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-fonts">
|
||||
{BOOK_FONT_ORDER.map((id) => {
|
||||
const face = BOOK_FONTS[id];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`st-font ${font === id ? 'is-on' : ''}`}
|
||||
style={{ fontFamily: face.stack }}
|
||||
onClick={() =>
|
||||
onPick({
|
||||
font: id,
|
||||
// A family that does not ship this weight would render a
|
||||
// synthesised bold, a smeared regular in print, so the
|
||||
// weight moves to the nearest one it really has.
|
||||
weight: nearestWeight(id, weight) as BookWeight,
|
||||
})
|
||||
}
|
||||
title={face.name}
|
||||
>
|
||||
{face.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The four weights, with the ones the family does not ship greyed out. */
|
||||
/**
|
||||
* A slider over the weights the family really has, and only those.
|
||||
*
|
||||
* Nine chips from 100 to 900 with most of them greyed out read as a broken
|
||||
* control for a family with two weights, and as a wall for one with nine.
|
||||
* The slider steps through what exists, says the weight by its name and its
|
||||
* number, and a family with a single weight shows that weight and nothing to
|
||||
* drag.
|
||||
*/
|
||||
export function WeightLine({
|
||||
font,
|
||||
weight,
|
||||
@@ -60,22 +29,36 @@ export function WeightLine({
|
||||
t,
|
||||
}: {
|
||||
font: string;
|
||||
weight: BookWeight;
|
||||
weight: number;
|
||||
onPick: (weight: BookWeight) => void;
|
||||
t: (k: string) => string;
|
||||
t: T;
|
||||
}) {
|
||||
const weights = weightsOf(font);
|
||||
const drawn = nearestWeight(font, weight);
|
||||
const at = Math.max(0, weights.indexOf(drawn as BookWeight));
|
||||
const word = `${t(`journey.studio.weight.${drawn}`)} ${drawn}`;
|
||||
return (
|
||||
<Line label={t('journey.studio.weight')}>
|
||||
<Choice
|
||||
value={weight}
|
||||
options={WEIGHTS.map((w) => ({
|
||||
value: w,
|
||||
label: String(w),
|
||||
disabled: !hasWeight(font, w),
|
||||
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
<div className="st-field st-weight">
|
||||
{weights.length > 1 ? (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={weights.length - 1}
|
||||
step={1}
|
||||
value={at}
|
||||
aria-label={t('journey.studio.weight')}
|
||||
aria-valuetext={word}
|
||||
onChange={(e) => {
|
||||
const next = weights[Number(e.target.value)];
|
||||
if (next !== undefined) onPick(next);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="st-weight-only">{t('journey.studio.weightOnly')}</span>
|
||||
)}
|
||||
<span className="st-weight-value">{word}</span>
|
||||
</div>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
@@ -140,8 +140,8 @@ export function TravelElementView({ el, frameStyle, big = false }: {
|
||||
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
|
||||
* stats block was to not use one.
|
||||
*/
|
||||
const heavy = (el: { weight: number }) => el.weight
|
||||
const light = (el: { weight: number }) => Math.max(400, el.weight - 100)
|
||||
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
|
||||
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
|
||||
|
||||
const MAP_PALETTES = {
|
||||
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
|
||||
|
||||
@@ -1,43 +1,213 @@
|
||||
/**
|
||||
* The @font-face rules behind the book's typefaces.
|
||||
*
|
||||
* bookFonts.ts names the seven families; this is where the faces they need
|
||||
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
|
||||
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
|
||||
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
|
||||
* they are down here rather than up there.
|
||||
* The catalogue in @trek/shared (book-fonts.data.ts) names the families, the
|
||||
* weights each ships and the weights it has a real italic for; this is where
|
||||
* those faces arrive, one import per face. Poppins 300 to 700 and
|
||||
* MuseoModerno 400, 700 and 800 come with the app chrome in main.tsx and are
|
||||
* not repeated here: importing a face twice only duplicates every rule.
|
||||
*
|
||||
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
|
||||
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
|
||||
* cost the app's first paint nothing. Once that chunk is loaded they are
|
||||
* ordinary stylesheets on the document, which is what lets printSheets copy
|
||||
* them into the print frame with everything else.
|
||||
* is set in Lora or Anton, so the rules travel with the Studio chunk and cost
|
||||
* the app's first paint nothing. Once that chunk is loaded they are ordinary
|
||||
* stylesheets on the document, which is what lets printSheets copy them into
|
||||
* the print frame with everything else.
|
||||
*
|
||||
* Upright only, as everywhere else in the app: the editor's italic toggle has
|
||||
* always been the browser's synthetic slant, and a second file per weight is a
|
||||
* lot of download to change that.
|
||||
* A rule is not a download. The browser fetches a face's file only when text
|
||||
* on the page is set in it, and only the subsets (latin, latin-ext, cyrillic
|
||||
* and so on) whose characters that text uses, so a book set in two families
|
||||
* loads two families however long this list grows. The print frame loads the
|
||||
* same way, and the assistant's page snapshot embeds only the faces a page
|
||||
* draws (snapshotFonts.ts).
|
||||
*
|
||||
* Real italics for the serifs, where the browser's slant reads as a mistake;
|
||||
* the sans faces keep the synthetic oblique they always had, which saves a
|
||||
* second file per weight nobody would tell apart.
|
||||
*
|
||||
* Big Shoulders Display is imported without `.css`: its package maps `./*` to
|
||||
* `./*.css` and has no `./*.css` entry of its own, so `100.css` would resolve
|
||||
* to `100.css.css` and fail the build.
|
||||
*/
|
||||
import '@fontsource/poppins/100.css'
|
||||
import '@fontsource/poppins/200.css'
|
||||
import '@fontsource/poppins/800.css'
|
||||
import '@fontsource/poppins/900.css'
|
||||
|
||||
import '@fontsource/inter/100.css'
|
||||
import '@fontsource/inter/200.css'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/600.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
import '@fontsource/inter/800.css'
|
||||
import '@fontsource/inter/900.css'
|
||||
|
||||
import '@fontsource/montserrat/100.css'
|
||||
import '@fontsource/montserrat/200.css'
|
||||
import '@fontsource/montserrat/300.css'
|
||||
import '@fontsource/montserrat/400.css'
|
||||
import '@fontsource/montserrat/500.css'
|
||||
import '@fontsource/montserrat/600.css'
|
||||
import '@fontsource/montserrat/700.css'
|
||||
import '@fontsource/montserrat/800.css'
|
||||
import '@fontsource/montserrat/900.css'
|
||||
|
||||
import '@fontsource/lora/400.css'
|
||||
import '@fontsource/lora/500.css'
|
||||
import '@fontsource/lora/600.css'
|
||||
import '@fontsource/lora/700.css'
|
||||
import '@fontsource/lora/400-italic.css'
|
||||
import '@fontsource/lora/500-italic.css'
|
||||
import '@fontsource/lora/600-italic.css'
|
||||
import '@fontsource/lora/700-italic.css'
|
||||
|
||||
import '@fontsource/eb-garamond/400.css'
|
||||
import '@fontsource/eb-garamond/500.css'
|
||||
import '@fontsource/eb-garamond/600.css'
|
||||
import '@fontsource/eb-garamond/700.css'
|
||||
import '@fontsource/eb-garamond/800.css'
|
||||
import '@fontsource/eb-garamond/400-italic.css'
|
||||
import '@fontsource/eb-garamond/500-italic.css'
|
||||
import '@fontsource/eb-garamond/600-italic.css'
|
||||
import '@fontsource/eb-garamond/700-italic.css'
|
||||
import '@fontsource/eb-garamond/800-italic.css'
|
||||
|
||||
import '@fontsource/playfair-display/400.css'
|
||||
import '@fontsource/playfair-display/500.css'
|
||||
import '@fontsource/playfair-display/600.css'
|
||||
import '@fontsource/playfair-display/700.css'
|
||||
import '@fontsource/playfair-display/800.css'
|
||||
import '@fontsource/playfair-display/900.css'
|
||||
import '@fontsource/playfair-display/400-italic.css'
|
||||
import '@fontsource/playfair-display/500-italic.css'
|
||||
import '@fontsource/playfair-display/600-italic.css'
|
||||
import '@fontsource/playfair-display/700-italic.css'
|
||||
import '@fontsource/playfair-display/800-italic.css'
|
||||
import '@fontsource/playfair-display/900-italic.css'
|
||||
|
||||
import '@fontsource/museomoderno/500.css'
|
||||
import '@fontsource/museomoderno/600.css'
|
||||
import '@fontsource/fraunces/100.css'
|
||||
import '@fontsource/fraunces/200.css'
|
||||
import '@fontsource/fraunces/300.css'
|
||||
import '@fontsource/fraunces/400.css'
|
||||
import '@fontsource/fraunces/500.css'
|
||||
import '@fontsource/fraunces/600.css'
|
||||
import '@fontsource/fraunces/700.css'
|
||||
import '@fontsource/fraunces/800.css'
|
||||
import '@fontsource/fraunces/900.css'
|
||||
import '@fontsource/fraunces/100-italic.css'
|
||||
import '@fontsource/fraunces/200-italic.css'
|
||||
import '@fontsource/fraunces/300-italic.css'
|
||||
import '@fontsource/fraunces/400-italic.css'
|
||||
import '@fontsource/fraunces/500-italic.css'
|
||||
import '@fontsource/fraunces/600-italic.css'
|
||||
import '@fontsource/fraunces/700-italic.css'
|
||||
import '@fontsource/fraunces/800-italic.css'
|
||||
import '@fontsource/fraunces/900-italic.css'
|
||||
|
||||
import '@fontsource/cormorant-garamond/300.css'
|
||||
import '@fontsource/cormorant-garamond/400.css'
|
||||
import '@fontsource/cormorant-garamond/500.css'
|
||||
import '@fontsource/cormorant-garamond/600.css'
|
||||
import '@fontsource/cormorant-garamond/700.css'
|
||||
import '@fontsource/cormorant-garamond/300-italic.css'
|
||||
import '@fontsource/cormorant-garamond/400-italic.css'
|
||||
import '@fontsource/cormorant-garamond/500-italic.css'
|
||||
import '@fontsource/cormorant-garamond/600-italic.css'
|
||||
import '@fontsource/cormorant-garamond/700-italic.css'
|
||||
|
||||
import '@fontsource/dm-serif-display/400.css'
|
||||
import '@fontsource/dm-serif-display/400-italic.css'
|
||||
|
||||
import '@fontsource/bodoni-moda/400.css'
|
||||
import '@fontsource/bodoni-moda/500.css'
|
||||
import '@fontsource/bodoni-moda/600.css'
|
||||
import '@fontsource/bodoni-moda/700.css'
|
||||
import '@fontsource/bodoni-moda/800.css'
|
||||
import '@fontsource/bodoni-moda/900.css'
|
||||
import '@fontsource/bodoni-moda/400-italic.css'
|
||||
import '@fontsource/bodoni-moda/500-italic.css'
|
||||
import '@fontsource/bodoni-moda/600-italic.css'
|
||||
import '@fontsource/bodoni-moda/700-italic.css'
|
||||
import '@fontsource/bodoni-moda/800-italic.css'
|
||||
import '@fontsource/bodoni-moda/900-italic.css'
|
||||
|
||||
import '@fontsource/bebas-neue/400.css'
|
||||
|
||||
import '@fontsource/anton/400.css'
|
||||
|
||||
import '@fontsource/oswald/200.css'
|
||||
import '@fontsource/oswald/300.css'
|
||||
import '@fontsource/oswald/400.css'
|
||||
import '@fontsource/oswald/500.css'
|
||||
import '@fontsource/oswald/600.css'
|
||||
import '@fontsource/oswald/700.css'
|
||||
|
||||
import '@fontsource/league-gothic/400.css'
|
||||
|
||||
import '@fontsource/big-shoulders-display/100'
|
||||
import '@fontsource/big-shoulders-display/200'
|
||||
import '@fontsource/big-shoulders-display/300'
|
||||
import '@fontsource/big-shoulders-display/400'
|
||||
import '@fontsource/big-shoulders-display/500'
|
||||
import '@fontsource/big-shoulders-display/600'
|
||||
import '@fontsource/big-shoulders-display/700'
|
||||
import '@fontsource/big-shoulders-display/800'
|
||||
import '@fontsource/big-shoulders-display/900'
|
||||
|
||||
import '@fontsource/archivo-black/400.css'
|
||||
|
||||
import '@fontsource/museomoderno/100.css'
|
||||
import '@fontsource/museomoderno/200.css'
|
||||
import '@fontsource/museomoderno/300.css'
|
||||
import '@fontsource/museomoderno/500.css'
|
||||
import '@fontsource/museomoderno/600.css'
|
||||
import '@fontsource/museomoderno/900.css'
|
||||
|
||||
import '@fontsource/shrikhand/400.css'
|
||||
|
||||
import '@fontsource/alfa-slab-one/400.css'
|
||||
|
||||
import '@fontsource/bricolage-grotesque/200.css'
|
||||
import '@fontsource/bricolage-grotesque/300.css'
|
||||
import '@fontsource/bricolage-grotesque/400.css'
|
||||
import '@fontsource/bricolage-grotesque/500.css'
|
||||
import '@fontsource/bricolage-grotesque/600.css'
|
||||
import '@fontsource/bricolage-grotesque/700.css'
|
||||
import '@fontsource/bricolage-grotesque/800.css'
|
||||
|
||||
import '@fontsource/abril-fatface/400.css'
|
||||
|
||||
import '@fontsource/caveat/400.css'
|
||||
import '@fontsource/caveat/500.css'
|
||||
import '@fontsource/caveat/600.css'
|
||||
import '@fontsource/caveat/700.css'
|
||||
|
||||
import '@fontsource/dancing-script/400.css'
|
||||
import '@fontsource/dancing-script/500.css'
|
||||
import '@fontsource/dancing-script/600.css'
|
||||
import '@fontsource/dancing-script/700.css'
|
||||
|
||||
import '@fontsource/pacifico/400.css'
|
||||
|
||||
import '@fontsource/great-vibes/400.css'
|
||||
|
||||
import '@fontsource/sacramento/400.css'
|
||||
|
||||
import '@fontsource/kaushan-script/400.css'
|
||||
|
||||
import '@fontsource/allura/400.css'
|
||||
|
||||
import '@fontsource/parisienne/400.css'
|
||||
|
||||
import '@fontsource/satisfy/400.css'
|
||||
|
||||
import '@fontsource/yellowtail/400.css'
|
||||
|
||||
import '@fontsource/homemade-apple/400.css'
|
||||
|
||||
import '@fontsource/permanent-marker/400.css'
|
||||
|
||||
import '@fontsource/dm-mono/300.css'
|
||||
import '@fontsource/dm-mono/400.css'
|
||||
import '@fontsource/dm-mono/500.css'
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { bookSample, faceSpec, useWarmBookFaces, warmBookFaces } from './bookFontWarm';
|
||||
|
||||
/**
|
||||
* The open book's faces go into the offline cache as it opens
|
||||
* (FE-STUDIO-FONTWARM): each face it uses is asked for once, with the book's
|
||||
* own characters, and a face that does not load costs nothing.
|
||||
*/
|
||||
|
||||
const frame = { x: 10, y: 10, w: 80, h: 20 };
|
||||
const text = (id: string, words: string, over: Record<string, unknown> = {}) =>
|
||||
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, ...over }) as BookElement;
|
||||
|
||||
function book(elements: BookElement[]): BookDocument {
|
||||
const doc = blankBook({ title: 'Kraków', newId: (p) => `${p}-x` });
|
||||
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
|
||||
return { ...doc, spreads: [{ ...cover, elements }, ...rest] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.setState({ doc: null });
|
||||
});
|
||||
|
||||
describe('the faces of a book', () => {
|
||||
it('FE-STUDIO-FONTWARM-001: samples each character of the book once, the title and the figures with it', () => {
|
||||
const sample = bookSample(book([text('t1', 'Łódź Łódź'), text('t2', ' ')]));
|
||||
expect(sample).toContain('Ł');
|
||||
expect(sample).toContain('ó');
|
||||
expect(sample).toContain('K');
|
||||
expect(sample.startsWith('0123456789')).toBe(true);
|
||||
expect(new Set(sample).size).toBe(sample.length);
|
||||
expect(sample).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-002: names a face the way document.fonts reads it', () => {
|
||||
expect(faceSpec({ font: 'serif', weight: 700, italic: true })).toBe('italic 700 16px "Lora"');
|
||||
expect(faceSpec({ font: 'caveat', weight: 400, italic: false })).toBe('400 16px "Caveat"');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-003: asks for every face once, counts the ones that loaded and never rejects', async () => {
|
||||
const doc = book([
|
||||
text('t1', 'Porto', { font: 'caveat', weight: 700 }),
|
||||
text('t2', 'Douro', { font: 'serif', italic: true }),
|
||||
]);
|
||||
const load = vi.fn(async (spec: string, _text?: string) => (spec.includes('Caveat') ? [{} as FontFace] : []));
|
||||
expect(await warmBookFaces(doc, { load })).toBe(1);
|
||||
const specs = load.mock.calls.map(([spec]) => spec);
|
||||
expect(specs).toEqual(expect.arrayContaining(['700 16px "Caveat"', 'italic 400 16px "Lora"']));
|
||||
expect(load.mock.calls[0]?.[1]).toContain('P');
|
||||
|
||||
const broken = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
expect(await warmBookFaces(doc, { load: broken })).toBe(0);
|
||||
expect(await warmBookFaces(doc, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FONTWARM-004: warms when the set of faces changes, not on every edit of the words', async () => {
|
||||
const load = vi.fn(async () => [] as FontFace[]);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load } });
|
||||
const doc = book([text('t1', 'Porto', { font: 'anton' })]);
|
||||
act(() => useStudioStore.setState({ doc }));
|
||||
renderHook(() => useWarmBookFaces());
|
||||
await act(async () => {});
|
||||
const first = load.mock.calls.length;
|
||||
expect(first).toBeGreaterThan(0);
|
||||
|
||||
const retyped = book([text('t1', 'Porto, again', { font: 'anton' })]);
|
||||
await act(async () => useStudioStore.setState({ doc: retyped }));
|
||||
expect(load.mock.calls.length).toBe(first);
|
||||
|
||||
const another = book([text('t1', 'Porto', { font: 'anton' }), text('t2', 'Lisboa', { font: 'vibes' })]);
|
||||
await act(async () => useStudioStore.setState({ doc: another }));
|
||||
expect(load.mock.calls.length).toBeGreaterThan(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BOOK_FONT_FACES, bookFacesUsed, type BookDocument, type BookFaceUse } from '@trek/shared';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
|
||||
/**
|
||||
* The faces of the open book, loaded as soon as it opens, so it reads in its
|
||||
* own type offline as well.
|
||||
*
|
||||
* The service worker installs the latin files of the seven families the book
|
||||
* shipped with (vite.config.js); every other face is cached by its runtime
|
||||
* rule the first time it is fetched. A face the browser only fetches once
|
||||
* text is drawn in it would be cached only for the pages someone happened to
|
||||
* look at, so a book opened online and read offline later would show its
|
||||
* third chapter in the fallback. Asking for every face the book uses, with the
|
||||
* characters it sets, fetches each one now, through the worker, into that
|
||||
* cache: a face nobody uses is never downloaded.
|
||||
*/
|
||||
|
||||
/** The book's own characters, for asking the right subsets of each face: its words, its title, the figures. */
|
||||
export function bookSample(doc: BookDocument): string {
|
||||
const seen = new Set<string>('0123456789');
|
||||
const take = (s: string) => {
|
||||
for (const ch of s) if (ch.trim()) seen.add(ch);
|
||||
};
|
||||
take(doc.title);
|
||||
for (const spread of doc.spreads) {
|
||||
for (const el of spread.elements) if (el.kind === 'text') take(el.text);
|
||||
}
|
||||
// A face's subsets are chosen by its unicode ranges, so one character of each is enough; the cap keeps it short.
|
||||
return [...seen].slice(0, 512).join('');
|
||||
}
|
||||
|
||||
/** One face as the CSS font shorthand `document.fonts.load` takes. */
|
||||
export function faceSpec(use: BookFaceUse): string {
|
||||
const family = BOOK_FONT_FACES[use.font].name;
|
||||
return `${use.italic ? 'italic ' : ''}${use.weight} 16px "${family}"`;
|
||||
}
|
||||
|
||||
type FontLoader = Pick<FontFaceSet, 'load'>;
|
||||
|
||||
/** Fetch every face the book needs; resolves with how many loaded, never rejects. */
|
||||
export async function warmBookFaces(
|
||||
doc: BookDocument,
|
||||
fonts: FontLoader | undefined = globalThis.document?.fonts
|
||||
): Promise<number> {
|
||||
if (!fonts) return 0;
|
||||
const sample = bookSample(doc);
|
||||
const loads = await Promise.allSettled(bookFacesUsed(doc).map((use) => fonts.load(faceSpec(use), sample)));
|
||||
return loads.filter((r) => r.status === 'fulfilled' && r.value.length > 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warms the faces of the book the Studio holds whenever the set of faces it
|
||||
* uses changes; typing into a text does not, so an edit costs nothing here.
|
||||
*/
|
||||
export function useWarmBookFaces(): void {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
|
||||
useEffect(() => {
|
||||
const current = useStudioStore.getState().doc;
|
||||
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
|
||||
if (key && current) void warmBookFaces(current);
|
||||
}, [key]);
|
||||
}
|
||||
@@ -1,118 +1,51 @@
|
||||
import {
|
||||
BOOK_FONT_CATEGORIES, BOOK_FONT_FACES, BOOK_FONTS_IDS, bookFontHasItalic, bookFontHasWeight, bookLighterWeight,
|
||||
nearestBookWeight,
|
||||
type BookFontCategory, type BookFontFace, type BookFontFamily,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The book's typefaces.
|
||||
* The book's typefaces, as the Studio draws them.
|
||||
*
|
||||
* ── Why this file exists ──────────────────────────────────────────────────
|
||||
*
|
||||
* The font stack used to name Georgia for its serif. Georgia is a system font:
|
||||
* it is on most desktops and on no Linux container, so the print renderer would
|
||||
* quietly substitute something else and a book proofread in one typeface would
|
||||
* print in another. The existing Journey PDF export has the same bug today — it
|
||||
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
|
||||
* print in another.
|
||||
*
|
||||
* So every family here is self-hosted through @fontsource, all of them OFL, and
|
||||
* So every family is self-hosted through @fontsource, OFL or Apache 2.0, and
|
||||
* every stack ends in a generic rather than in another named font nobody
|
||||
* shipped. What the editor draws is what the renderer has.
|
||||
*
|
||||
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
|
||||
* here and not imported there falls back to exactly the system font this file
|
||||
* was written against, which is how five of the seven shipped (#2183).
|
||||
*
|
||||
* ── Why these seven ───────────────────────────────────────────────────────
|
||||
*
|
||||
* A photo book needs more than one voice and fewer than a menu. Two workhorse
|
||||
* sans faces, three serifs across the range from bookish to high-contrast, and
|
||||
* two display faces for a cover. Anything past that is a font picker rather
|
||||
* than a design decision.
|
||||
* The catalogue itself (names, stacks, weights, italics, roles) lives in
|
||||
* @trek/shared (book-fonts.data.ts), because the findings, the design engine
|
||||
* and the assistant read the same families. This file only gives the Studio
|
||||
* its view of it; the faces are loaded next door in bookFontFaces.ts.
|
||||
*/
|
||||
|
||||
export interface BookFont {
|
||||
export type BookFontId = BookFontFamily
|
||||
|
||||
export interface BookFont extends BookFontFace {
|
||||
id: BookFontId
|
||||
/** What it is called, shown as itself in the picker. */
|
||||
name: string
|
||||
/** The CSS stack. Ends generic — never in a font that is not bundled. */
|
||||
stack: string
|
||||
/** Where it belongs, for grouping the picker. */
|
||||
kind: 'sans' | 'serif' | 'display'
|
||||
/**
|
||||
* Weights actually loaded. Asking for 700 on a family that ships one weight
|
||||
* gets a synthesised bold, which prints as a smeared version of the regular.
|
||||
*/
|
||||
weights: number[]
|
||||
}
|
||||
|
||||
export type BookFontId =
|
||||
| 'sans' | 'serif' | 'display'
|
||||
| 'inter' | 'garamond' | 'playfair' | 'bebas'
|
||||
|
||||
/**
|
||||
* `sans`, `serif` and `display` keep their names because documents already
|
||||
* contain them — the three original slots, now pointing at bundled families
|
||||
* rather than at whatever the machine happened to have.
|
||||
*/
|
||||
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
|
||||
sans: {
|
||||
id: 'sans',
|
||||
name: 'Poppins',
|
||||
stack: '"Poppins", system-ui, sans-serif',
|
||||
kind: 'sans',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
inter: {
|
||||
id: 'inter',
|
||||
name: 'Inter',
|
||||
stack: '"Inter", system-ui, sans-serif',
|
||||
kind: 'sans',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
serif: {
|
||||
id: 'serif',
|
||||
name: 'Lora',
|
||||
stack: '"Lora", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
garamond: {
|
||||
id: 'garamond',
|
||||
name: 'EB Garamond',
|
||||
stack: '"EB Garamond", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
playfair: {
|
||||
id: 'playfair',
|
||||
name: 'Playfair Display',
|
||||
stack: '"Playfair Display", Georgia, serif',
|
||||
kind: 'serif',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
display: {
|
||||
id: 'display',
|
||||
name: 'MuseoModerno',
|
||||
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
|
||||
kind: 'display',
|
||||
weights: [400, 500, 600, 700],
|
||||
},
|
||||
bebas: {
|
||||
id: 'bebas',
|
||||
name: 'Bebas Neue',
|
||||
stack: '"Bebas Neue", Impact, sans-serif',
|
||||
kind: 'display',
|
||||
// One weight only. Asking for bold gets a synthesised one, which is why
|
||||
// the inspector greys the weights this family does not have.
|
||||
weights: [400],
|
||||
},
|
||||
}
|
||||
export const BOOK_FONTS = Object.fromEntries(
|
||||
BOOK_FONTS_IDS.map(id => [id, { id, ...BOOK_FONT_FACES[id] }]),
|
||||
) as unknown as Record<BookFontId, BookFont>
|
||||
|
||||
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
|
||||
// app's voice rather than the book's, and a book set in the interface font
|
||||
// reads as a screenshot of the app.
|
||||
|
||||
/** The picker's order: workhorses first, display last. */
|
||||
export const BOOK_FONT_ORDER: BookFontId[] = [
|
||||
'sans', 'inter',
|
||||
'serif', 'garamond', 'playfair',
|
||||
'display', 'bebas',
|
||||
]
|
||||
/** The picker's order: by category (sans first, mono last), the catalogue's order within one. */
|
||||
export const BOOK_FONT_ORDER: BookFontId[] = BOOK_FONT_CATEGORIES.flatMap(category =>
|
||||
BOOK_FONTS_IDS.filter(id => BOOK_FONT_FACES[id].category === category))
|
||||
|
||||
/** The families of one picker group, in picker order. */
|
||||
export function fontsIn(category: BookFontCategory): BookFontId[] {
|
||||
return BOOK_FONT_ORDER.filter(id => BOOK_FONTS[id].category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS stack for a font id.
|
||||
@@ -126,20 +59,20 @@ export function fontStack(id: string): string {
|
||||
}
|
||||
|
||||
/** Whether a family actually ships the weight being asked for. */
|
||||
export function hasWeight(id: string, weight: number): boolean {
|
||||
const font = BOOK_FONTS[id as BookFontId]
|
||||
return font ? font.weights.includes(weight) : true
|
||||
}
|
||||
export const hasWeight = bookFontHasWeight
|
||||
|
||||
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
|
||||
export const hasItalic = bookFontHasItalic
|
||||
|
||||
/**
|
||||
* The nearest weight a family really has.
|
||||
*
|
||||
* A synthesised bold is a smeared regular in print, so a family with one weight
|
||||
* gets that weight rather than a request the renderer has to fake.
|
||||
* gets that weight rather than a request the renderer has to fake. The
|
||||
* renderer draws every element through this as well, so a book whose weight
|
||||
* was set before its family changed still prints in a face that exists.
|
||||
*/
|
||||
export function nearestWeight(id: string, weight: number): number {
|
||||
const font = BOOK_FONTS[id as BookFontId]
|
||||
if (!font || font.weights.includes(weight)) return weight
|
||||
return font.weights.reduce((best, w) =>
|
||||
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
|
||||
}
|
||||
export const nearestWeight = nearestBookWeight
|
||||
|
||||
/** The lighter weight a travel element sets beside its figures, one the family has. */
|
||||
export const lighterWeight = bookLighterWeight
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BOOK_FONT_CATEGORIES, type BookFontCategory, type BookWeight } from '@trek/shared';
|
||||
import { BOOK_FONTS, fontsIn, hasItalic, nearestWeight, type BookFontId } from './bookFonts';
|
||||
|
||||
/**
|
||||
* The type controls' decisions, apart from their markup: which families a
|
||||
* search finds, the weight a newly picked family is set at, and what the row
|
||||
* and the italic button say. StudioFontPicker.tsx and StudioTypeControls.tsx
|
||||
* draw them.
|
||||
*/
|
||||
|
||||
export type FontT = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface FontPick {
|
||||
font: BookFontId;
|
||||
weight: BookWeight;
|
||||
}
|
||||
|
||||
/** The family, its weight moved onto one the new family has. */
|
||||
export function pickFont(id: BookFontId, weight: number): FontPick {
|
||||
// A family that does not ship this weight would render a synthesised bold,
|
||||
// a smeared regular in print, so the weight moves to the nearest one it has.
|
||||
return { font: id, weight: nearestWeight(id, weight) as BookWeight };
|
||||
}
|
||||
|
||||
/** The families that match a search, by name, id or group, in picker order. */
|
||||
export function matchFonts(query: string, groupName: (c: BookFontCategory) => string): BookFontId[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return BOOK_FONT_CATEGORIES.flatMap((category) => {
|
||||
const group = groupName(category).toLowerCase();
|
||||
return fontsIn(category).filter(
|
||||
(id) => !q || BOOK_FONTS[id].name.toLowerCase().includes(q) || id.includes(q) || group.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The weights a family has, as its row says them: one number, or the first and the last. */
|
||||
export function weightSpan(id: BookFontId, t: FontT): string {
|
||||
const ws = BOOK_FONTS[id].weights;
|
||||
const first = ws[0] ?? 400;
|
||||
const last = ws[ws.length - 1] ?? first;
|
||||
return first === last ? String(first) : t('journey.studio.fonts.range', { from: first, to: last });
|
||||
}
|
||||
|
||||
/** The italic button's words: plain where the family has a real italic, the reason where the browser slants it. */
|
||||
export function italicTitle(font: string, weight: number, t: FontT): string {
|
||||
return hasItalic(font, nearestWeight(font, weight)) ? t('journey.studio.italic') : t('journey.studio.italicSlanted');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
|
||||
import { snapshotFontCss } from './snapshotFonts';
|
||||
|
||||
/**
|
||||
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
|
||||
@@ -126,6 +127,8 @@ export async function snapshotSpread(
|
||||
hiddenPhotoIds: ReadonlySet<number>
|
||||
): Promise<PageSnapshot | null> {
|
||||
await picturesLoaded(root, hiddenPhotoIds);
|
||||
// Only the faces the page draws travel with it (snapshotFonts.ts); without any, the library looks for itself.
|
||||
const fontCss = await snapshotFontCss(root);
|
||||
// A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
|
||||
const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
|
||||
const { domToBlob } = await import('modern-screenshot');
|
||||
@@ -137,6 +140,7 @@ export async function snapshotSpread(
|
||||
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
|
||||
filter: (node) => keepNode(node, hiddenPhotoIds),
|
||||
timeout: LOAD_TIMEOUT_MS,
|
||||
...(fontCss ? { font: { cssText: fontCss } } : {}),
|
||||
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
|
||||
});
|
||||
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
coversAny,
|
||||
cssWeight,
|
||||
faceRulesOf,
|
||||
faceUsesOf,
|
||||
parseFaceRule,
|
||||
parseUnicodeRange,
|
||||
pickFaceRules,
|
||||
snapshotFontCss,
|
||||
type FaceRule,
|
||||
} from './snapshotFonts';
|
||||
|
||||
/**
|
||||
* The faces a page snapshot carries (FE-STUDIO-SNAPFONT): what the page draws
|
||||
* is read off the DOM, the browser's choice of rule is repeated, and only the
|
||||
* faces it would pick go into the image, each with its file inline.
|
||||
*/
|
||||
|
||||
const BASE = 'https://trek.example/assets/app.css';
|
||||
const LATIN = 'U+0000-00FF, U+0131, U+0152-0153';
|
||||
const EXT = 'U+0100-02BA, U+1E00-1E9F';
|
||||
|
||||
const rule = (family: string, weight: number, italic: boolean, range: string, file: string): FaceRule => ({
|
||||
family,
|
||||
weight,
|
||||
italic,
|
||||
unicodeRange: range,
|
||||
url: `https://trek.example/assets/${file}`,
|
||||
});
|
||||
|
||||
const RULES = [
|
||||
rule('Lora', 400, false, LATIN, 'lora-latin-400-normal.woff2'),
|
||||
rule('Lora', 400, false, EXT, 'lora-latin-ext-400-normal.woff2'),
|
||||
rule('Lora', 700, false, LATIN, 'lora-latin-700-normal.woff2'),
|
||||
rule('Lora', 400, true, LATIN, 'lora-latin-400-italic.woff2'),
|
||||
rule('Caveat', 400, false, LATIN, 'caveat-latin-400-normal.woff2'),
|
||||
rule('Anton', 400, false, LATIN, 'anton-latin-400-normal.woff2'),
|
||||
];
|
||||
|
||||
const chars = (s: string) => new Set([...s].map((c) => c.codePointAt(0) ?? 0));
|
||||
const files = (picked: FaceRule[]) => picked.map((r) => r.url.split('/').pop()).sort();
|
||||
|
||||
function page(html: string): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = html;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('reading a rule', () => {
|
||||
it('FE-STUDIO-SNAPFONT-001: reads family, weight, slant, range and the woff2 file of an @font-face rule', () => {
|
||||
const css =
|
||||
"@font-face { font-family: 'Lora'; font-style: italic; font-display: swap; font-weight: 700; " +
|
||||
'src: url(./files/lora-latin-700-italic.woff2) format("woff2"), url(./files/lora-latin-700-italic.woff) format("woff"); ' +
|
||||
`unicode-range: ${LATIN}; }`;
|
||||
expect(parseFaceRule(css, BASE)).toEqual({
|
||||
family: 'Lora',
|
||||
weight: 700,
|
||||
italic: true,
|
||||
unicodeRange: LATIN,
|
||||
url: 'https://trek.example/assets/files/lora-latin-700-italic.woff2',
|
||||
});
|
||||
const plain = '@font-face { font-family: "Anton"; src: url("/assets/anton.woff") format("woff"); }';
|
||||
expect(parseFaceRule(plain, BASE)).toMatchObject({
|
||||
weight: 400,
|
||||
italic: false,
|
||||
unicodeRange: '',
|
||||
url: 'https://trek.example/assets/anton.woff',
|
||||
});
|
||||
expect(parseFaceRule('.x { color: red; }', BASE)).toBeNull();
|
||||
expect(parseFaceRule('@font-face { font-family: "NoFile"; }', BASE)).toBeNull();
|
||||
expect(parseFaceRule('@font-face { src: url(a.woff2); }', BASE)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-002: reads weights and unicode ranges as CSS does', () => {
|
||||
expect([cssWeight('bold'), cssWeight('normal'), cssWeight('300'), cssWeight('5000')]).toEqual([700, 400, 300, 400]);
|
||||
expect(parseUnicodeRange('U+0000-00FF, U+0131, U+4??')).toEqual([
|
||||
[0, 0xff],
|
||||
[0x131, 0x131],
|
||||
[0x400, 0x4ff],
|
||||
]);
|
||||
expect(parseUnicodeRange('U+zz, ')).toEqual([]);
|
||||
expect(coversAny(LATIN, chars('Lisboa'))).toBe(true);
|
||||
expect(coversAny(EXT, chars('Lisboa'))).toBe(false);
|
||||
expect(coversAny(EXT, chars('Łódź'))).toBe(true);
|
||||
expect(coversAny('', chars('anything'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choosing the faces', () => {
|
||||
it('FE-STUDIO-SNAPFONT-003: picks one rule per subset the text reaches, at the weight and slant it is set in', () => {
|
||||
const uses = new Map([
|
||||
['lora|700|false', chars('Porto')],
|
||||
['caveat|400|false', chars('travel')],
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, uses))).toEqual(['caveat-latin-400-normal.woff2', 'lora-latin-700-normal.woff2']);
|
||||
// Polish needs the extended subset beside the basic one.
|
||||
const polish = new Map([['lora|400|false', chars('Kraków Łódź')]]);
|
||||
expect(files(pickFaceRules(RULES, polish))).toEqual([
|
||||
'lora-latin-400-normal.woff2',
|
||||
'lora-latin-ext-400-normal.woff2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-004: falls back the way the browser does: the nearest weight, the upright face, nothing for an unknown family', () => {
|
||||
expect(files(pickFaceRules(RULES, new Map([['lora|600|false', chars('a')]])))).toEqual([
|
||||
'lora-latin-700-normal.woff2',
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, new Map([['lora|400|true', chars('a')]])))).toEqual([
|
||||
'lora-latin-400-italic.woff2',
|
||||
]);
|
||||
expect(files(pickFaceRules(RULES, new Map([['anton|400|true', chars('a')]])))).toEqual([
|
||||
'anton-latin-400-normal.woff2',
|
||||
]);
|
||||
expect(pickFaceRules(RULES, new Map([['georgia|400|false', chars('a')]]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading the page', () => {
|
||||
it('FE-STUDIO-SNAPFONT-005: reads every face the text under the root is set in, with its characters', () => {
|
||||
const root = page(
|
||||
'<p style="font-family: \'Lora\', Georgia, serif; font-weight: 700">Porto</p>' +
|
||||
'<svg><text style="font-family: Caveat, cursive; font-style: italic">ab</text></svg>' +
|
||||
'<p style="font-family: Lora; font-weight: bold">Té</p>' +
|
||||
'<p style="font-family: Anton"> </p>'
|
||||
);
|
||||
const uses = faceUsesOf(root);
|
||||
expect([...uses.keys()].sort()).toEqual(['caveat|400|true', 'lora|700|false']);
|
||||
expect(uses.get('lora|700|false')).toEqual(chars('PortoTé'));
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-006: reads the face rules of the stylesheets it may read, inside grouping rules too', () => {
|
||||
const face = (family: string) => ({
|
||||
cssText: `@font-face { font-family: "${family}"; src: url(/assets/${family}.woff2) format("woff2"); }`,
|
||||
});
|
||||
const fake = {
|
||||
baseURI: 'https://trek.example/',
|
||||
styleSheets: [
|
||||
{
|
||||
href: null,
|
||||
cssRules: [face('Lora'), { cssText: '.x{}' }, { cssText: '@media print {}', cssRules: [face('Anton')] }],
|
||||
},
|
||||
{
|
||||
href: 'https://cdn.example/a.css',
|
||||
get cssRules(): never {
|
||||
throw new DOMException('cross origin', 'SecurityError');
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as Document;
|
||||
expect(faceRulesOf(fake).map((r) => [r.family, r.url])).toEqual([
|
||||
['Lora', 'https://trek.example/assets/Lora.woff2'],
|
||||
['Anton', 'https://trek.example/assets/Anton.woff2'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the snapshot css', () => {
|
||||
it('FE-STUDIO-SNAPFONT-007: inlines exactly the faces the page draws, and leaves out one that will not load', async () => {
|
||||
const root = page(
|
||||
'<p style="font-family: Lora; font-weight: 700">Porto</p><p style="font-family: Caveat">travel</p>'
|
||||
);
|
||||
const load = vi.fn(async (url: string) =>
|
||||
url.includes('caveat') ? null : new Blob(['font'], { type: 'font/woff2' })
|
||||
);
|
||||
const css = await snapshotFontCss(root, load, RULES);
|
||||
expect(load.mock.calls.map(([url]) => url.split('/').pop()).sort()).toEqual([
|
||||
'caveat-latin-400-normal.woff2',
|
||||
'lora-latin-700-normal.woff2',
|
||||
]);
|
||||
expect(css).toContain('@font-face{font-family:"Lora";font-style:normal;font-weight:700;unicode-range:U+0000-00FF');
|
||||
expect(css).toContain('src:url(data:font/woff2;base64,');
|
||||
expect(css).not.toContain('Caveat');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-SNAPFONT-008: answers null when the page draws no bundled face or none of its files load, so the library looks for itself', async () => {
|
||||
const root = page('<p style="font-family: Georgia">Porto</p>');
|
||||
expect(await snapshotFontCss(root, vi.fn(), RULES)).toBeNull();
|
||||
const lora = page('<p style="font-family: Lora">Porto</p>');
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
expect(await snapshotFontCss(lora, failing, RULES)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* The typefaces a page snapshot for the assistant carries, and only those.
|
||||
*
|
||||
* modern-screenshot draws the page as an SVG image, and an SVG image cannot
|
||||
* reach the document's fonts, so every face it shows has to travel inside it.
|
||||
* Left to itself the library embeds every @font-face rule of every family the
|
||||
* page names: all nine weights of Montserrat, both styles, every script
|
||||
* subset, woff and woff2. With thirty four book families that is megabytes of
|
||||
* fonts per page for a picture whose text uses one weight of two families.
|
||||
*
|
||||
* So the snapshot reads what the page really draws (family, weight, slant and
|
||||
* the characters set in it), picks the one rule per subset the browser would
|
||||
* pick for that, and hands the library exactly those faces as data URLs. A
|
||||
* page set in Lora and Caveat embeds the latin files of Lora and Caveat, and
|
||||
* nothing else.
|
||||
*/
|
||||
|
||||
/** One @font-face rule, as far as the choice of face goes. */
|
||||
export interface FaceRule {
|
||||
family: string;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
/** The CSS unicode-range, or '' for a face that covers everything. */
|
||||
unicodeRange: string;
|
||||
/** The woff2 file, else the first file the rule names, resolved to a full URL. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** What the page draws in one face: the characters set in it. */
|
||||
type FaceUses = Map<string, Set<number>>;
|
||||
|
||||
const faceKey = (family: string, weight: number, italic: boolean) => `${family.toLowerCase()}|${weight}|${italic}`;
|
||||
|
||||
const unquote = (v: string) => v.trim().replace(/^["']|["']$/g, '');
|
||||
|
||||
/** A rule's descriptors by name, read from its text: `font-weight: 700` and the like. */
|
||||
function descriptors(css: string): Map<string, string> {
|
||||
const body = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}'));
|
||||
const out = new Map<string, string>();
|
||||
for (const part of body.split(';')) {
|
||||
const at = part.indexOf(':');
|
||||
if (at > 0) out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A CSS weight as a number: the keywords as CSS reads them, anything else as 400. */
|
||||
export function cssWeight(value: string): number {
|
||||
if (value === 'bold') return 700;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : 400;
|
||||
}
|
||||
|
||||
/** The file a rule's src names, the woff2 one first, resolved against where the stylesheet came from. */
|
||||
function sourceOf(src: string, base: string): string | null {
|
||||
const urls = [...src.matchAll(/url\((["']?)([^"')]+)\1\) ?(?:format\(["']?([\w-]+)["']?\))?/g)];
|
||||
const pick = urls.find((m) => m[3] === 'woff2') ?? urls[0];
|
||||
if (!pick?.[2]) return null;
|
||||
try {
|
||||
return new URL(pick[2], base).href;
|
||||
} catch {
|
||||
// A source no URL can be made of is a face the snapshot does without.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One @font-face rule read from its text, or null for any other rule or one that names no file. */
|
||||
export function parseFaceRule(css: string, base: string): FaceRule | null {
|
||||
if (!css.trimStart().toLowerCase().startsWith('@font-face')) return null;
|
||||
const d = descriptors(css);
|
||||
const family = unquote(d.get('font-family') ?? '');
|
||||
const url = sourceOf(d.get('src') ?? '', base);
|
||||
if (!family || !url) return null;
|
||||
return {
|
||||
family,
|
||||
weight: cssWeight(d.get('font-weight') || '400'),
|
||||
italic: /italic|oblique/i.test(d.get('font-style') ?? ''),
|
||||
unicodeRange: d.get('unicode-range') ?? '',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/** A unicode-range as code point spans: `U+0000-00FF, U+0131, U+4??` all read. */
|
||||
export function parseUnicodeRange(range: string): [number, number][] {
|
||||
return range
|
||||
.split(',')
|
||||
.map((part) => part.trim().replace(/^u\+/i, ''))
|
||||
.filter(Boolean)
|
||||
.flatMap((part): [number, number][] => {
|
||||
if (part.includes('?')) {
|
||||
return [[Number.parseInt(part.replace(/\?/g, '0'), 16), Number.parseInt(part.replace(/\?/g, 'F'), 16)]];
|
||||
}
|
||||
const [from, to] = part.split('-');
|
||||
const a = Number.parseInt(from ?? '', 16);
|
||||
const b = to === undefined ? a : Number.parseInt(to, 16);
|
||||
return Number.isFinite(a) && Number.isFinite(b) ? [[a, b]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a rule's range takes any of the characters; a rule without one takes them all. */
|
||||
export function coversAny(range: string, chars: ReadonlySet<number>): boolean {
|
||||
if (!range) return true;
|
||||
const spans = parseUnicodeRange(range);
|
||||
for (const c of chars) if (spans.some(([a, b]) => c >= a && c <= b)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules the browser would draw these uses with: per family and slant, the
|
||||
* nearest weight it has (the renderer only ever asks for weights a family
|
||||
* ships, so this is the exact one in practice), an upright face where there is
|
||||
* no italic, and of those only the subsets whose range the text reaches.
|
||||
*/
|
||||
export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceRule[] {
|
||||
const picked = new Map<string, FaceRule>();
|
||||
for (const [key, chars] of uses) {
|
||||
const [family = '', weightText = '400', italicText = 'false'] = key.split('|');
|
||||
const weight = Number(weightText);
|
||||
const ofFamily = rules.filter((r) => r.family.toLowerCase() === family);
|
||||
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
|
||||
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
|
||||
if (!styled.length) continue;
|
||||
const nearest = styled.reduce((best, r) =>
|
||||
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
|
||||
).weight;
|
||||
for (const rule of styled) {
|
||||
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
|
||||
}
|
||||
}
|
||||
return [...picked.values()];
|
||||
}
|
||||
|
||||
/** The faces the text under `root` is set in, each with the characters it sets. */
|
||||
export function faceUsesOf(root: Element): FaceUses {
|
||||
const uses: FaceUses = new Map();
|
||||
const view = root.ownerDocument.defaultView;
|
||||
if (!view) return uses;
|
||||
const walker = root.ownerDocument.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const words = node.textContent ?? '';
|
||||
const parent = node.parentElement;
|
||||
if (!parent || !words.trim()) continue;
|
||||
const style = view.getComputedStyle(parent);
|
||||
const family = unquote(style.fontFamily.split(',')[0] ?? '');
|
||||
if (!family) continue;
|
||||
const key = faceKey(family, cssWeight(style.fontWeight), /italic|oblique/.test(style.fontStyle));
|
||||
const chars = uses.get(key) ?? new Set<number>();
|
||||
for (const ch of words) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code !== undefined && code > 32) chars.add(code);
|
||||
}
|
||||
uses.set(key, chars);
|
||||
}
|
||||
return uses;
|
||||
}
|
||||
|
||||
/** Every @font-face rule the document's stylesheets hold, inside grouping rules too. */
|
||||
export function faceRulesOf(doc: Document): FaceRule[] {
|
||||
const out: FaceRule[] = [];
|
||||
const walk = (list: CSSRuleList, base: string) => {
|
||||
for (const rule of Array.from(list)) {
|
||||
const face = parseFaceRule(rule.cssText, base);
|
||||
if (face) out.push(face);
|
||||
else if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const sheet of Array.from(doc.styleSheets)) {
|
||||
let rules: CSSRuleList;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
// A stylesheet from another origin cannot be read, and holds no book face.
|
||||
continue;
|
||||
}
|
||||
walk(rules, sheet.href ?? doc.baseURI);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A file as a data URL. */
|
||||
function asDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('font read failed'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
type FetchFont = (url: string) => Promise<Blob | null>;
|
||||
|
||||
/** A face file from this server, out of the cache when the page has already drawn it. */
|
||||
const fetchFont: FetchFont = async (url) => {
|
||||
if (new URL(url).origin !== window.location.origin) return null;
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'force-cache',
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
return res.ok ? res.blob() : null;
|
||||
};
|
||||
|
||||
/** One face as a rule the snapshot can carry: its file inline. */
|
||||
async function inlineRule(rule: FaceRule, load: FetchFont): Promise<string | null> {
|
||||
try {
|
||||
const blob = await load(rule.url);
|
||||
if (!blob) return null;
|
||||
const range = rule.unicodeRange ? `unicode-range:${rule.unicodeRange};` : '';
|
||||
const family = rule.family.replace(/"/g, '');
|
||||
const style = rule.italic ? 'italic' : 'normal';
|
||||
return `@font-face{font-family:"${family}";font-style:${style};font-weight:${rule.weight};${range}src:url(${await asDataUrl(blob)});}`;
|
||||
} catch {
|
||||
// A face that cannot be fetched is drawn in its fallback; the page still goes.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The @font-face rules for the faces the page under `root` draws, with their
|
||||
* files inline, ready for modern-screenshot's `font.cssText`. Null when there
|
||||
* is nothing to carry, so the caller lets the library look for itself.
|
||||
*/
|
||||
export async function snapshotFontCss(
|
||||
root: Element,
|
||||
load: FetchFont = fetchFont,
|
||||
available: readonly FaceRule[] = faceRulesOf(root.ownerDocument)
|
||||
): Promise<string | null> {
|
||||
const rules = pickFaceRules(available, faceUsesOf(root));
|
||||
if (!rules.length) return null;
|
||||
const inlined = await Promise.all(rules.map((rule) => inlineRule(rule, load)));
|
||||
const css = inlined.filter((r): r is string => r !== null);
|
||||
return css.length ? css.join('\n') : null;
|
||||
}
|
||||
@@ -625,7 +625,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<TourPlannerMapStage
|
||||
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
|
||||
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} assistantRoutes={assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressG
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
@@ -74,6 +75,11 @@ export interface TourPlannerMapStageProps
|
||||
onViewportChange?: (bounds: FerrataBbox) => void;
|
||||
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
|
||||
paddleChecklist?: PageState['paddleChecklist'];
|
||||
/** The routes the assistant's cards show and its camera, as on the plan's map, so a proposed hike shows here too. */
|
||||
assistantRoutes?: Pick<
|
||||
AssistantMapProps,
|
||||
'assistantCamera' | 'assistantRouteLines' | 'onAssistantRouteHover' | 'onAssistantRoutePoint'
|
||||
>;
|
||||
}
|
||||
|
||||
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
|
||||
@@ -104,6 +110,7 @@ export function TourPlannerMapStage({
|
||||
rightPanelPx,
|
||||
onViewportChange,
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
@@ -285,6 +292,7 @@ export function TourPlannerMapStage({
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
{...poiMarkers}
|
||||
{...assistantRoutes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { PlaceFormOpeners } from './usePlaceFormOpeners';
|
||||
|
||||
/**
|
||||
* A place the assistant found, opened in the place form when its card is
|
||||
* clicked: filled in with what the search knew, the details column reading
|
||||
* the rest (photos, hours, description) by its Google id. The form is the
|
||||
* planner's own, so the place is saved as any other, on the day in focus.
|
||||
*/
|
||||
export function useAssistantPlaceDraft(tripId: number, openAddPlaceFromPoi: PlaceFormOpeners['openAddPlaceFromPoi']) {
|
||||
const draft = useAssistantStore((s) => (s.placeDraft?.tripId === tripId ? s.placeDraft : null));
|
||||
const takePlaceDraft = useAssistantStore((s) => s.takePlaceDraft);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
takePlaceDraft(draft.seq);
|
||||
const { place } = draft;
|
||||
openAddPlaceFromPoi(
|
||||
{
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
name: place.name,
|
||||
address: place.address,
|
||||
website: null,
|
||||
phone: null,
|
||||
osm_id: place.osmId ?? '',
|
||||
google_place_id: place.googlePlaceId,
|
||||
poi_type: place.kind,
|
||||
},
|
||||
draft.dayId
|
||||
);
|
||||
}, [draft, openAddPlaceFromPoi, takePlaceDraft]);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker: all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; category?: string | null; poi_type?: string | null },
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; google_place_id?: string | null; category?: string | null; poi_type?: string | null },
|
||||
dayId?: number | null,
|
||||
/** Index within that day. Omitted, the place is appended, which is what every caller did before. */
|
||||
position?: number | null,
|
||||
@@ -100,6 +100,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// A plugin POI's `plugin:<pluginId>:<id>` rides along as it is. The server never
|
||||
// takes that prefix for a Google place id, so the details column makes no Google call.
|
||||
osm_id: poi.osm_id,
|
||||
google_place_id: poi.google_place_id || undefined,
|
||||
// What the map search filed it under, so the form can preselect a category (#2282).
|
||||
category: poi.poi_type || poi.category || undefined,
|
||||
stop_type: stop?.stopType ?? null,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
|
||||
const { tripId, tripActions, searchParams, setSearchParams } = options
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; google_place_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
// Day context of the open form. Set only by the day-scoped entry points (the
|
||||
// mobile day toolbar, a long-press on the mobile map); every other opener
|
||||
|
||||
@@ -52,6 +52,7 @@ import { usePlannerDialogs } from './usePlannerDialogs'
|
||||
import { usePlaceDeepLink } from './usePlaceDeepLink'
|
||||
import { useAssistantDeepLink } from './useAssistantDeepLink'
|
||||
import { useAssistantCompareView, useAssistantMapLink } from './useAssistantMapLink'
|
||||
import { useAssistantPlaceDraft } from './useAssistantPlaceDraft'
|
||||
import { usePlannerMapView } from './usePlannerMapView'
|
||||
import { useMapPlaces } from './useMapPlaces'
|
||||
import { useRoadtripFeed } from './useRoadtripFeed'
|
||||
@@ -593,6 +594,7 @@ export function useTripPlanner() {
|
||||
setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm,
|
||||
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace,
|
||||
})
|
||||
useAssistantPlaceDraft(tripId, openAddPlaceFromPoi)
|
||||
|
||||
const {
|
||||
handleReorder, handleReorderDays, handleUpdateDayTitle, handleMoveToDay, handleRemoveAssignment,
|
||||
|
||||
@@ -79,7 +79,12 @@ describe('aiConnectionsRepo', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-CONN-REPO-004: reads and writes the admin settings and the instance connection', async () => {
|
||||
const settings = { policy: 'user' as const, dailyTurns: 50, retention: { mode: 'forever' as const } };
|
||||
const settings = {
|
||||
policy: 'user' as const,
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'forever' as const },
|
||||
};
|
||||
const connection: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
...VIEW,
|
||||
@@ -102,6 +107,7 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.saveAdminSettings({ ...settings, dailyTurns: 20 })).resolves.toEqual({
|
||||
...settings,
|
||||
dailyTurns: 20,
|
||||
webSearch: true,
|
||||
});
|
||||
await expect(aiConnectionsRepo.adminConnection()).resolves.toEqual(connection);
|
||||
await expect(aiConnectionsRepo.adminModels()).resolves.toEqual([
|
||||
|
||||
@@ -63,6 +63,8 @@ export function applyChunk(
|
||||
]);
|
||||
case 'data-trek-weather':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
|
||||
case 'data-trek-sources':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-sources', data: chunk.data }]);
|
||||
case 'data-trek-follow-ups':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-follow-ups', data: chunk.data }]);
|
||||
case 'data-trek-card':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-005
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-006
|
||||
*
|
||||
* Which routes of the chat's cards the plan's map draws, and how: changes
|
||||
* that still stand on the map with the way they replace, the cards of the
|
||||
@@ -127,6 +127,20 @@ describe('assistantRouteModel', () => {
|
||||
expect(refs[1]?.card).toBe(SAVED);
|
||||
});
|
||||
|
||||
it('FE-AI-ROUTE-006: once one of the ways is taken into the trip, the answer draws none of them; a discarded take brings them back', () => {
|
||||
const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1');
|
||||
const take = (approval: Approval) => {
|
||||
const parts = held('t1', PENDING, approval);
|
||||
const change = parts[1] as Extract<AssistantMessagePart, { type: 'data-trek-change' }>;
|
||||
change.data = { ...change.data, takenFrom: 'call_2' };
|
||||
return message(parts, 'msg-2');
|
||||
};
|
||||
const waiting = routeRefs([latest, take({})], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(waiting.map((ref) => `${ref.toolCallId}:${ref.use}`)).toEqual(['t1:before', 't1:pending']);
|
||||
const discarded = routeRefs([latest, take({ approved: false })], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(discarded.map((ref) => ref.toolCallId)).toEqual(['call_1', 'call_2']);
|
||||
});
|
||||
|
||||
it("FE-AI-ROUTE-003: lines lie in order (replaced way, others, the chosen way, the lit one) in their ways' colours", () => {
|
||||
const previous = { line: flat(HIKE), distanceM: 8000, ascentM: null, minutes: null };
|
||||
const messages = [
|
||||
|
||||
@@ -101,7 +101,8 @@ function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined,
|
||||
/**
|
||||
* The cards of the latest answer that draws any, laid out as the chat lays
|
||||
* them out: a group of several is a choice of ways, each with its letter, the
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop.
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop, and so
|
||||
* do the ways of a group once one of them was taken into the trip.
|
||||
*/
|
||||
function lookRefs(
|
||||
messages: readonly AssistantMessage[],
|
||||
@@ -111,6 +112,7 @@ function lookRefs(
|
||||
layout: AnswerLayout
|
||||
): RouteRef[] {
|
||||
const chosenIds = new Set(Object.values(picks));
|
||||
const taken = takenProposals(messages);
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const answers = (messages[index]?.parts ?? []).flatMap((part): RouteCardEntry[] =>
|
||||
part.type === 'data-trek-card' && part.data.card && !changeIds.has(part.data.toolCallId)
|
||||
@@ -121,6 +123,7 @@ function lookRefs(
|
||||
const drawn = new Set(lined.map((entry) => entry.toolCallId));
|
||||
if (drawn.size === 0) continue;
|
||||
return layout(answers).flatMap((group) => {
|
||||
if (group.some((entry) => taken.has(entry.toolCallId))) return [];
|
||||
const ways = group.length >= 2;
|
||||
const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0];
|
||||
return group.flatMap((entry, position): RouteRef[] => {
|
||||
@@ -133,6 +136,28 @@ function lookRefs(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposals taken into the trip ("Plan this") and not discarded since:
|
||||
* each stands as its change from then on, so its answer's ways leave the map.
|
||||
*/
|
||||
function takenProposals(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || !part.approval) continue;
|
||||
if (part.approval.approved === false || part.approval.resolution) dropped.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
const taken = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'data-trek-change' || !part.data.takenFrom) continue;
|
||||
if (!dropped.has(part.data.toolCallId)) taken.add(part.data.takenFrom);
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card of the chat the map draws now, in the part it plays: the
|
||||
* changes that still stand on the map (each with the way it replaces), then
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AssistantDecision,
|
||||
type AssistantErrorCode,
|
||||
type AssistantFocus,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantMapView,
|
||||
type AssistantMention,
|
||||
type AssistantMessage,
|
||||
@@ -149,6 +150,8 @@ interface AssistantState {
|
||||
routeFocus: { tripId: number; ref: string; focus: RouteProfileFocus; source: 'card' | 'map' } | null;
|
||||
/** A line a card asks the map to draw while the pointer rests on it (a day of a drive, a saved tour). */
|
||||
routePreview: { tripId: number; ref: string; lines: LatLng[][]; color: string } | null;
|
||||
/** A found place to open in the planner's place form, filled in; `seq` tells a new one from the same place asked again. */
|
||||
placeDraft: { tripId: number; seq: number; place: AssistantFoundPlace; dayId: number | null } | null;
|
||||
/** The route card the phone shows over the map. */
|
||||
routePeek: { tripId: number; toolCallId: string } | null;
|
||||
/** Where the floating window sits after its last move or resize, for the map to frame around it. */
|
||||
@@ -207,6 +210,9 @@ interface AssistantState {
|
||||
|
||||
setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void;
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
|
||||
/** Opens a found place in the planner's place form; the planner takes it with `takePlaceDraft`. */
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) => void;
|
||||
takePlaceDraft: (seq: number) => void;
|
||||
setPreviewPin: (preview: AssistantState['preview']) => void;
|
||||
setWindowRect: (rect: WindowRect | null) => void;
|
||||
setMapView: (mapView: AssistantState['mapView']) => void;
|
||||
@@ -820,6 +826,11 @@ function mapActions(ctx: Ctx) {
|
||||
},
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') =>
|
||||
requestCameraOn(ctx, tripId, points, mode, why),
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) =>
|
||||
ctx.set({ placeDraft: { tripId, seq: (ctx.get().placeDraft?.seq ?? 0) + 1, place, dayId } }),
|
||||
takePlaceDraft: (seq: number) => {
|
||||
if (ctx.get().placeDraft?.seq === seq) ctx.set({ placeDraft: null });
|
||||
},
|
||||
setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
|
||||
setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
|
||||
setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }),
|
||||
@@ -923,6 +934,7 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
|
||||
mapLink: null,
|
||||
camera: null,
|
||||
preview: null,
|
||||
placeDraft: null,
|
||||
windowRect: null,
|
||||
mapView: null,
|
||||
studioHosts: {},
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* ============================================================================
|
||||
TREK Studio: the typeface picker and the weight slider.
|
||||
|
||||
Its own file beside studio.css, which is at its size ceiling. Same rules as
|
||||
there: every selector is scoped under `.trek-dash` (dashboard.css resets
|
||||
`button` at a specificity a bare class loses to), and every colour is one of
|
||||
the dashboard tokens, so the picker follows the user's theme and accent.
|
||||
|
||||
The family names are set in their own faces and at the user's text size,
|
||||
which is why they scale with --fs-scale-body rather than sitting at a fixed
|
||||
pixel size: a picker is read, and a name too small to read is a name that
|
||||
tells you nothing about its face.
|
||||
============================================================================ */
|
||||
|
||||
.trek-dash .st-fontpick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-current,
|
||||
.trek-dash .st-fontpick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
color: var(--ink);
|
||||
transition:
|
||||
background 0.14s,
|
||||
border-color 0.14s,
|
||||
color 0.14s;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-current {
|
||||
height: 40px;
|
||||
padding-inline: 10px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface);
|
||||
}
|
||||
.trek-dash .st-fontpick-current:hover {
|
||||
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
|
||||
}
|
||||
.trek-dash .st-fontpick-current:focus-visible,
|
||||
.trek-dash .st-fontpick-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.trek-dash .st-fontpick.is-open .st-fontpick-current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-face {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
font-size: calc(16px * var(--fs-scale-body, 1));
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-span {
|
||||
flex: none;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-caret {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
.trek-dash .st-fontpick.is-open .st-fontpick-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* The open list. It sits in the inspector's flow and scrolls on its own, so
|
||||
the inspector around it keeps its place. */
|
||||
.trek-dash .st-fontpick-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklch, var(--surface) 70%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.trek-dash .st-fontpick-search {
|
||||
margin: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px 4px 6px;
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-group + .st-fontpick-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.trek-dash .st-fontpick-label {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 8px 8px 4px;
|
||||
font-size: calc(10.5px * var(--fs-scale-caption, 1));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
background: color-mix(in oklch, var(--surface) 92%, transparent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-row {
|
||||
min-height: 38px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-fontpick-row:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-fontpick-row.is-on {
|
||||
color: var(--ink);
|
||||
background: color-mix(in oklch, var(--accent) 10%, transparent);
|
||||
}
|
||||
.trek-dash .st-fontpick-tick {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.trek-dash .st-fontpick-none {
|
||||
padding: 10px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ----------------- weight ----------------- */
|
||||
.trek-dash .st-weight {
|
||||
width: 100%;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-value,
|
||||
.trek-dash .st-weight > .st-weight-only {
|
||||
font-size: calc(11.5px * var(--fs-scale-caption, 1));
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-value {
|
||||
flex: none;
|
||||
min-width: 84px;
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-weight > .st-weight-only {
|
||||
flex: 1;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-fontpick-caret,
|
||||
.trek-dash .st-fontpick-current,
|
||||
.trek-dash .st-fontpick-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1583,37 +1583,7 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ----------------- font picker ----------------- *
|
||||
* Each family set in itself. A row of chips reading "Poppins / Lora / Bebas" in
|
||||
* the interface font tells you their names and nothing about how they look,
|
||||
* which is the only thing anyone is choosing between.
|
||||
*/
|
||||
.trek-dash .st-fonts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.trek-dash .st-font {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklch, var(--surface) 55%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
transition: background .14s, border-color .14s, color .14s;
|
||||
}
|
||||
.trek-dash .st-font:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .st-font.is-on {
|
||||
color: var(--ink);
|
||||
border-color: color-mix(in oklch, var(--accent) 50%, transparent);
|
||||
background: color-mix(in oklch, var(--accent) 10%, transparent);
|
||||
}
|
||||
/* The typeface picker lives in studio-fonts.css. */
|
||||
.trek-dash .st-chip:disabled { opacity: .32; cursor: default; }
|
||||
|
||||
/* ----------------- save state ----------------- *
|
||||
|
||||
Vendored
+10
@@ -3,3 +3,13 @@
|
||||
|
||||
/** The version this bundle was built as (client/package.json), set by `define` in vite.config.js. */
|
||||
declare const __TREK_UI_VERSION__: string;
|
||||
|
||||
/**
|
||||
* Big Shoulders Display's weights, imported for their stylesheets alone
|
||||
* (components/Studio/bookFontFaces.ts). Its package maps `./*` to `./*.css`
|
||||
* and has no `./*.css` entry, so the import names `100` rather than
|
||||
* `100.css`, and vite/client's `*.css` declaration does not cover a bare
|
||||
* name. Declared for this one package only, so a mistyped import of any
|
||||
* other module still fails the check.
|
||||
*/
|
||||
declare module '@fontsource/big-shoulders-display/*';
|
||||
|
||||
@@ -145,6 +145,11 @@ describe('TranslationProvider', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-I18NCTX-018: plural forms follow the language of the strings on screen, not the one still loading', async () => {
|
||||
// The chunk is loaded here first: FE-COMP-I18NCTX-011a starts every locale at
|
||||
// once without waiting, and the Russian one could otherwise queue behind the
|
||||
// others past any timeout. The provider still switches asynchronously, so the
|
||||
// first render below is English all the same.
|
||||
await import('@trek/shared/i18n/ru');
|
||||
withLanguage('ru');
|
||||
render(<TranslationProvider><Probe tKey="places.count" params={{ count: 21 }} /></TranslationProvider>);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { APP_FACES, bookFontFile, keepInPrecache, withoutRuntimeFonts } from '../../../bookFontPrecache.js';
|
||||
|
||||
/**
|
||||
* Which font files the service worker installs (FE-SW-FONTS): the app's own
|
||||
* faces as before, the latin woff2 of the seven book families that shipped
|
||||
* first, and nothing else of the book's catalogue, which waits in the
|
||||
* book-fonts runtime cache for a book that uses it.
|
||||
*/
|
||||
|
||||
// Vitest runs with the client package as its root, so cwd is stable here.
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8');
|
||||
|
||||
const asset = (name: string) => `assets/${name}`;
|
||||
|
||||
describe('reading a font file name', () => {
|
||||
it('FE-SW-FONTS-001: reads package, subset, weight and style as the build names them, and nothing else as a book face', () => {
|
||||
expect(bookFontFile(asset('dm-serif-display-latin-ext-400-italic-Ab12_cD3.woff2'))).toEqual({
|
||||
pkg: 'dm-serif-display',
|
||||
subset: 'latin-ext',
|
||||
weight: 400,
|
||||
italic: true,
|
||||
ext: 'woff2',
|
||||
});
|
||||
expect(bookFontFile(asset('dm-mono-latin-500-normal-xyz.woff'))).toMatchObject({ pkg: 'dm-mono', subset: 'latin' });
|
||||
expect(bookFontFile(asset('geist-sans-latin-400-normal-abc.woff2'))).toBeNull();
|
||||
expect(bookFontFile(asset('index-abc.js'))).toBeNull();
|
||||
expect(bookFontFile(asset('lora.woff2'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the precache', () => {
|
||||
it('FE-SW-FONTS-002: keeps the faces the app draws, whatever their subset and format', () => {
|
||||
expect(keepInPrecache(asset('poppins-latin-500-normal-abc.woff'))).toBe(true);
|
||||
expect(keepInPrecache(asset('poppins-devanagari-700-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('museomoderno-latin-800-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('geist-sans-latin-600-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('index-abc.js'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-SW-FONTS-003: keeps only the latin woff2 of the seven book families that shipped first', () => {
|
||||
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('bebas-neue-latin-400-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('museomoderno-latin-500-normal-abc.woff2'))).toBe(true);
|
||||
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff'))).toBe(false);
|
||||
expect(keepInPrecache(asset('lora-cyrillic-400-normal-abc.woff2'))).toBe(false);
|
||||
expect(keepInPrecache(asset('lora-latin-400-italic-abc.woff2'))).toBe(false);
|
||||
expect(keepInPrecache(asset('eb-garamond-latin-800-normal-abc.woff2'))).toBe(false);
|
||||
expect(keepInPrecache(asset('poppins-latin-900-normal-abc.woff2'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-SW-FONTS-004: leaves every file of the new families to the runtime cache', () => {
|
||||
for (const name of [
|
||||
'anton-latin-400-normal-abc.woff2',
|
||||
'caveat-latin-700-normal-abc.woff2',
|
||||
'fraunces-latin-ext-900-italic-abc.woff',
|
||||
]) {
|
||||
expect(keepInPrecache(asset(name)), name).toBe(false);
|
||||
}
|
||||
const manifest = [
|
||||
{ url: asset('index-abc.js'), revision: null },
|
||||
{ url: asset('anton-latin-400-normal-abc.woff2'), revision: null },
|
||||
{ url: asset('inter-latin-400-normal-abc.woff2'), revision: null },
|
||||
];
|
||||
expect(withoutRuntimeFonts(manifest)).toEqual({ manifest: [manifest[0], manifest[2]], warnings: [] });
|
||||
});
|
||||
|
||||
it('FE-SW-FONTS-005: knows the app faces main.tsx imports, so the chrome never loses one offline', () => {
|
||||
const imported = [...read('src/main.tsx').matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)].map(
|
||||
(m) => `${m[1]}/${m[2]}`
|
||||
);
|
||||
const declared = Object.entries(APP_FACES).flatMap(([pkg, weights]) => weights.map((w) => `${pkg}/${w}`));
|
||||
expect(imported.filter((face) => !face.startsWith('geist-sans/')).sort()).toEqual(declared.sort());
|
||||
});
|
||||
|
||||
it('FE-SW-FONTS-006: is wired into the worker, with the runtime rule the Studio fills', () => {
|
||||
const config = read('vite.config.js');
|
||||
expect(config).toContain('manifestTransforms: [withoutRuntimeFonts]');
|
||||
const rule =
|
||||
/urlPattern:\s*(\/[^\n]*woff2[^\n]*\/i),\s*\n\s*handler:\s*'CacheFirst',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'book-fonts'/.exec(
|
||||
config
|
||||
);
|
||||
expect(rule).not.toBeNull();
|
||||
const literal = rule?.[1] ?? '//';
|
||||
const pattern = new RegExp(literal.slice(1, literal.lastIndexOf('/')), 'i');
|
||||
expect(pattern.test('https://trek.example/assets/anton-latin-400-normal-abc.woff2')).toBe(true);
|
||||
expect(pattern.test('https://trek.example/assets/lora-latin-400-normal-abc.woff')).toBe(true);
|
||||
expect(pattern.test('https://trek.example/assets/index-abc.js')).toBe(false);
|
||||
expect(pattern.test('https://trek.example/api/photos/1/file.woff2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,30 +23,40 @@ const STUDIO = 'src/components/Studio/bookFontFaces.ts'
|
||||
// Vitest runs with the client package as its root, so cwd is stable here.
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8')
|
||||
|
||||
/** Every `@fontsource/<package>/<weight>.css` a file imports. */
|
||||
/** Every `@fontsource/<package>/<weight>[-italic][.css]` a file imports (see bookFontFaces.ts for the bare form). */
|
||||
function facesIn(rel: string): string[] {
|
||||
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)]
|
||||
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+(?:-italic)?)(?:\.css)?'/g)]
|
||||
.map(m => `${m[1]}/${m[2]}`)
|
||||
}
|
||||
|
||||
/** @fontsource names its packages after the family, lowercased and hyphenated. */
|
||||
function faceOf(id: BookFontId, weight: number): string {
|
||||
return `${BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-')}/${weight}`
|
||||
/** A face as the import names it: the family's package, the weight and the italic. */
|
||||
function faceOf(id: BookFontId, weight: number, italic = false): string {
|
||||
return `${BOOK_FONTS[id].pkg}/${weight}${italic ? '-italic' : ''}`
|
||||
}
|
||||
|
||||
/** Every face the catalogue declares: each weight upright and each real italic. */
|
||||
const declared = BOOK_FONTS_IDS.flatMap(id => [
|
||||
...BOOK_FONTS[id].weights.map(w => faceOf(id, w)),
|
||||
...BOOK_FONTS[id].italics.map(w => faceOf(id, w, true)),
|
||||
])
|
||||
|
||||
describe('the bundled faces', () => {
|
||||
it('loads every family the picker offers, in the weights it offers', () => {
|
||||
const loaded = [...facesIn(APP), ...facesIn(STUDIO)]
|
||||
for (const face of declared) {
|
||||
expect(loaded, face).toContain(face)
|
||||
}
|
||||
})
|
||||
|
||||
/* @fontsource names its packages after the family, lowercased and hyphenated. */
|
||||
it('takes every family from the package named after it', () => {
|
||||
for (const id of BOOK_FONTS_IDS) {
|
||||
for (const weight of BOOK_FONTS[id].weights) {
|
||||
expect(loaded, `${BOOK_FONTS[id].name} ${weight}`).toContain(faceOf(id, weight))
|
||||
}
|
||||
expect(BOOK_FONTS[id].pkg, id).toBe(BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-'))
|
||||
}
|
||||
})
|
||||
|
||||
/* A weight nothing can ask for is a download nobody wanted. */
|
||||
it('loads no weight the registry does not declare', () => {
|
||||
const declared = BOOK_FONTS_IDS.flatMap(id => BOOK_FONTS[id].weights.map(w => faceOf(id, w)))
|
||||
for (const face of facesIn(STUDIO)) {
|
||||
expect(declared, face).toContain(face)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { BOOK_FONTS_IDS, bookTextElementSchema } from '@trek/shared'
|
||||
import {
|
||||
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, hasWeight, nearestWeight,
|
||||
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, fontsIn, hasItalic, hasWeight, nearestWeight,
|
||||
} from '../../../src/components/Studio/bookFonts'
|
||||
|
||||
/**
|
||||
@@ -21,9 +21,13 @@ describe('the registry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('offers every family in the picker, in one order', () => {
|
||||
it('offers every family in the picker, in one order, grouped by category', () => {
|
||||
expect([...BOOK_FONT_ORDER].sort()).toEqual([...BOOK_FONTS_IDS].sort())
|
||||
expect(new Set(BOOK_FONT_ORDER).size).toBe(BOOK_FONT_ORDER.length)
|
||||
expect(BOOK_FONT_ORDER[0]).toBe('sans')
|
||||
expect(BOOK_FONT_ORDER[BOOK_FONT_ORDER.length - 1]).toBe('mono')
|
||||
expect(fontsIn('script')).toContain('caveat')
|
||||
expect(fontsIn('script').every(id => BOOK_FONTS[id].category === 'script')).toBe(true)
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -34,17 +38,17 @@ describe('the registry', () => {
|
||||
it('ends every stack in a generic family', () => {
|
||||
for (const id of BOOK_FONTS_IDS) {
|
||||
const last = BOOK_FONTS[id].stack.split(',').pop()!.trim()
|
||||
expect(['sans-serif', 'serif', 'monospace'], `${id}: ${last}`).toContain(last)
|
||||
expect(['sans-serif', 'serif', 'cursive', 'monospace'], `${id}: ${last}`).toContain(last)
|
||||
}
|
||||
})
|
||||
|
||||
it('names a bundled family first in every stack', () => {
|
||||
// The first entry is the one that will actually be used, so it is the one
|
||||
// that has to be self-hosted.
|
||||
const bundled = ['Poppins', 'Inter', 'Lora', 'EB Garamond', 'Playfair Display', 'MuseoModerno', 'Bebas Neue']
|
||||
// bookFontFaces.test.ts holds every family to the package it is loaded from.
|
||||
for (const id of BOOK_FONTS_IDS) {
|
||||
const first = BOOK_FONTS[id].stack.split(',')[0].replace(/"/g, '').trim()
|
||||
expect(bundled, `${id}: ${first}`).toContain(first)
|
||||
expect(first, id).toBe(BOOK_FONTS[id].name)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +102,12 @@ describe('weights', () => {
|
||||
it('moves to the nearest weight a family really has', () => {
|
||||
expect(nearestWeight('bebas', 700)).toBe(400)
|
||||
expect(nearestWeight('sans', 700)).toBe(700)
|
||||
expect(nearestWeight('oswald', 900)).toBe(700)
|
||||
})
|
||||
|
||||
it('knows which families have a real italic', () => {
|
||||
expect(hasItalic('garamond', 400)).toBe(true)
|
||||
expect(hasItalic('sans', 400)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an unknown family alone rather than guessing', () => {
|
||||
|
||||
@@ -101,6 +101,20 @@ const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { na
|
||||
/** A switch, or any plain button: both answer to their accessible name. */
|
||||
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
|
||||
|
||||
/** Opens the typeface list from its button and picks a family by the name its row is set in. */
|
||||
function pickFamily(name: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^journey\.studio\.fonts\.change: / }))
|
||||
const row = screen.getAllByRole('option').find(o => o.querySelector('.st-fontpick-face')?.textContent === name)
|
||||
if (!row) throw new Error(`no typeface row named ${name}`)
|
||||
fireEvent.click(row)
|
||||
}
|
||||
|
||||
/** Drags the weight slider to the stop of a weight among those the family ships. */
|
||||
function slideWeight(weights: readonly number[], weight: number) {
|
||||
const slider = screen.getByRole('slider', { name: 'journey.studio.weight' })
|
||||
fireEvent.change(slider, { target: { value: String(weights.indexOf(weight)) } })
|
||||
}
|
||||
|
||||
/** A section by its heading, since a label can repeat across sections. */
|
||||
function section(container: HTMLElement, label: string): HTMLElement {
|
||||
const found = [...container.querySelectorAll<HTMLElement>('.st-section')]
|
||||
@@ -512,7 +526,7 @@ describe('the tail all five share', () => {
|
||||
|
||||
// Bebas ships one weight; a bold it does not have prints as a smeared
|
||||
// regular, so the request moves rather than being passed through.
|
||||
press('Bebas Neue')
|
||||
pickFamily('Bebas Neue')
|
||||
|
||||
expect(saved().font).toBe('bebas')
|
||||
expect(saved().weight).toBe(400)
|
||||
@@ -522,7 +536,7 @@ describe('the tail all five share', () => {
|
||||
const { container } = show(statsEl({ font: 'sans', weight: 500 }))
|
||||
open(container, 'journey.studio.typography')
|
||||
|
||||
press('Lora')
|
||||
pickFamily('Lora')
|
||||
|
||||
expect(saved().font).toBe('serif')
|
||||
expect(saved().weight).toBe(500)
|
||||
@@ -532,7 +546,8 @@ describe('the tail all five share', () => {
|
||||
const { container } = show(countriesEl())
|
||||
open(container, 'journey.studio.typography')
|
||||
|
||||
pick('500')
|
||||
// Poppins ships 100 to 900, so 500 is the fifth stop.
|
||||
slideWeight([100, 200, 300, 400, 500, 600, 700, 800, 900], 500)
|
||||
|
||||
expect(saved().weight).toBe(500)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { BookDocument, BookRecord } from '@trek/shared'
|
||||
import { BOOK_SCHEMA_REV, type BookDocument, type BookRecord } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Autosaving a Studio book (#1973).
|
||||
@@ -851,13 +851,13 @@ describe('a book a newer Studio saved', () => {
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
})
|
||||
|
||||
/* A book of today goes out exactly as it went out before the revision existed. */
|
||||
it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
|
||||
/* A book goes out stamped with the revision of the Studio that wrote it, so an older one cannot save over it. */
|
||||
it('FE-STUDIO-REV-006: sends a book stamped with the revision of this build', async () => {
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('a'), 'T') })
|
||||
await tick()
|
||||
|
||||
expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
|
||||
expect(api.saveBook.mock.calls[0][1].document.schemaRev).toBe(BOOK_SCHEMA_REV)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
|
||||
import { withoutRuntimeFonts } from './bookFontPrecache.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// The version this bundle is built as, baked in at build time. The Docker build
|
||||
@@ -77,6 +78,11 @@ export default defineConfig(({ mode }) => ({
|
||||
// matching the globs below is fetched at service-worker install by every
|
||||
// user, whether or not they ever reach the code.
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
// Except most of the book's typefaces: of thirty four families only the
|
||||
// latin files of the seven the book first shipped with are installed,
|
||||
// the rest wait in the book-fonts rule below for a book that uses them
|
||||
// (bookFontPrecache.js).
|
||||
manifestTransforms: [withoutRuntimeFonts],
|
||||
// build:analyze drops a treemap next to the app; it must never end up in a
|
||||
// precache manifest if someone ships that build by accident.
|
||||
// The assistant's chat (@assistant-ui, about 110 kB gzip) only runs online, so
|
||||
@@ -92,6 +98,21 @@ export default defineConfig(({ mode }) => ({
|
||||
/^\/plugin-frame\//,
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// The book's typefaces the precache leaves out (bookFontPrecache.js):
|
||||
// fetched the first time a page is set in them, and up front for
|
||||
// every face of a book the Studio opens (bookFontWarm.ts), so the
|
||||
// book still reads in its own type offline. Same-origin build
|
||||
// assets only; the files are content-hashed, so a cached one never
|
||||
// goes stale. First in the list so no broader rule takes them.
|
||||
urlPattern: /\/assets\/[^/?#]+\.woff2?(?:\?.*)?$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'book-fonts',
|
||||
expiration: { maxEntries: 600, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
|
||||
|
||||
Generated
+270
@@ -33,14 +33,41 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||
"@fontsource/allura": "^5.3.0",
|
||||
"@fontsource/anton": "^5.3.0",
|
||||
"@fontsource/archivo-black": "^5.3.0",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/big-shoulders-display": "^5.3.0",
|
||||
"@fontsource/bodoni-moda": "^5.3.0",
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/caveat": "^5.3.0",
|
||||
"@fontsource/cormorant-garamond": "^5.3.0",
|
||||
"@fontsource/dancing-script": "^5.3.0",
|
||||
"@fontsource/dm-mono": "^5.3.0",
|
||||
"@fontsource/dm-serif-display": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/fraunces": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/great-vibes": "^5.3.0",
|
||||
"@fontsource/homemade-apple": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/kaushan-script": "^5.3.0",
|
||||
"@fontsource/league-gothic": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/oswald": "^5.3.0",
|
||||
"@fontsource/pacifico": "^5.3.0",
|
||||
"@fontsource/parisienne": "^5.3.0",
|
||||
"@fontsource/permanent-marker": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.3.0",
|
||||
"@fontsource/sacramento": "^5.3.0",
|
||||
"@fontsource/satisfy": "^5.3.0",
|
||||
"@fontsource/shrikhand": "^5.3.0",
|
||||
"@fontsource/yellowtail": "^5.3.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -3366,6 +3393,51 @@
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource/abril-fatface": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/abril-fatface/-/abril-fatface-5.3.0.tgz",
|
||||
"integrity": "sha512-voKCJEhGGZNdlk+OS3uFRSDV/HWbs6TAy7qKe9wXq96Uzwadfksyk5eyXLa1T5FkEazBgpHRMRSFBKfA3rbKbg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/alfa-slab-one": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/alfa-slab-one/-/alfa-slab-one-5.3.0.tgz",
|
||||
"integrity": "sha512-J/ANFZ7S9CbZClr/QhMjmBtpFmmKipVkEXHj20aurR5uqJPCBChVPJn1/kx6vtrwnfnDY0kQaYSTYasSt4GruA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/allura": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/allura/-/allura-5.3.0.tgz",
|
||||
"integrity": "sha512-UqloNNVxl03+psvtIXCYuo7/v2mD9XmX2HGiczhK6NmDDwbFJJDjrbHZ5tpNuVXKsMEQPc46tZu3hewYN82/YA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/anton": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz",
|
||||
"integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/archivo-black": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/archivo-black/-/archivo-black-5.3.0.tgz",
|
||||
"integrity": "sha512-7EsTIQ+Blf96ktpzTUpI7msi+u9/AJxM/GZxOSkEubVd1jSTrFSTNxMgfpX/8rQsjZ+19a+qvRbtcTEp5J/72g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bebas-neue": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
|
||||
@@ -3375,6 +3447,78 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/big-shoulders-display": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.3.0.tgz",
|
||||
"integrity": "sha512-d4+Tcsq/WfoKeqWfB6wFdeNDsSYYnilJbSNNpf5w53MlWrGHAc9YvkAjC6id6PgFDiGaFKsXfP0RFAQtpt0bog==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bodoni-moda": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bodoni-moda/-/bodoni-moda-5.3.0.tgz",
|
||||
"integrity": "sha512-Wt2XBCHivYxkTP3ydq/Ax+7VshwsOh/6ayA5om2t+yClCkGwLPj2Mi6OnyMANjyLcqDUWs4rlOHnc8SBLf6/pA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bricolage-grotesque": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
|
||||
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/caveat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
|
||||
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/cormorant-garamond": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.3.0.tgz",
|
||||
"integrity": "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dancing-script": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dancing-script/-/dancing-script-5.3.0.tgz",
|
||||
"integrity": "sha512-/FFbz4ZCNN+eG2a6phkZB41iKmyCC6rq2blA/kG9OFeI3/tpzLNazND1bBdEnogLhpfg7o4NfauTbEEYSpSUGg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dm-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dm-serif-display": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-serif-display/-/dm-serif-display-5.3.0.tgz",
|
||||
"integrity": "sha512-ecRclUcYeto9fB7ghkB36H4MtUoaUYFjjornWYYeLCMHzaWEDPk2kIWgZOq0mKBNRxclRSieKcFeLonlMYDTKg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/eb-garamond": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
|
||||
@@ -3384,6 +3528,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/fraunces": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.3.0.tgz",
|
||||
"integrity": "sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/geist-sans": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz",
|
||||
@@ -3393,6 +3546,24 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/great-vibes": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
|
||||
"integrity": "sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/homemade-apple": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/homemade-apple/-/homemade-apple-5.3.0.tgz",
|
||||
"integrity": "sha512-awJSYTbSY3SroC8nirEhDauHvERVvm0LI3FpUHsPZXl9Tht1xrqhiI8ro4yf3ROcvQ7CfFJ/2+DLcOHBOXhJKw==",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
|
||||
@@ -3402,6 +3573,24 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/kaushan-script": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/kaushan-script/-/kaushan-script-5.3.0.tgz",
|
||||
"integrity": "sha512-Kdcvr76wOGp2jgKkkmAOETmPcw/a34RU/qcGW2+gSKzkKyr03vEaLWSbSABoVKWuyuoqWoulcmC4571vq3BYvg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/league-gothic": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/league-gothic/-/league-gothic-5.3.0.tgz",
|
||||
"integrity": "sha512-gqedetX+UlaiJgLiPnOzWGB1sY3OtNhS8YMjTy2D73AcsagIqpdIizssr1MStcUYf3D/zzanECuXawIgMGZxDg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/lora": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz",
|
||||
@@ -3411,6 +3600,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/montserrat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
|
||||
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/museomoderno": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/museomoderno/-/museomoderno-5.3.0.tgz",
|
||||
@@ -3420,6 +3618,42 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/oswald": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.3.0.tgz",
|
||||
"integrity": "sha512-16r2aHActe0pH+9FLdk/74/YxXM7HpGak05kAzx3RvSToiJwGvyhv6GvckPz+YuZmvL1nxWbEop2jAXRcXYPRg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/pacifico": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.3.0.tgz",
|
||||
"integrity": "sha512-bWv0fHR1n36p7scRn+maaEaVeQCOq9yvfVfjyAeUzKrK4Y/3qiRAEj4hJgfxj/sjgZZGkeHyKPrbWdDaZtpCVw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/parisienne": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/parisienne/-/parisienne-5.3.0.tgz",
|
||||
"integrity": "sha512-YbfjZDYy7FMTVE0HRgejkGX3T5iFfgagOYtAAAymq9GklviFAZ1GqIdlyviNe23TiR20f2K7zxsL4AECtWnkEQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/permanent-marker": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.3.0.tgz",
|
||||
"integrity": "sha512-ZO/NtYW4SCc/iY8N3dmjWyDITcXJ/vhWZFgnwRcvzwCKKnScMrM/a2/6VHlsjCVuIgmPzNrCFbedEYhv+/jJBg==",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/playfair-display": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz",
|
||||
@@ -3438,6 +3672,42 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/sacramento": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/sacramento/-/sacramento-5.3.0.tgz",
|
||||
"integrity": "sha512-KkqN52aTsJ0pSBva/NREqFljs0M1TwMIpNcqdSQc2EKR/mVU1CvM2dazf9eG19rHMTIssjbia83nGG705SlD1w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/satisfy": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/satisfy/-/satisfy-5.3.0.tgz",
|
||||
"integrity": "sha512-dQ+Cy85XdwYnwlzWcbkmA2Qkpxam0ZHDaGm/qoGHlyHKRVgM/BJN8RLHpRm31R0REFBQe5Yl8Ke5oTdpcKdPIA==",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/shrikhand": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/shrikhand/-/shrikhand-5.3.0.tgz",
|
||||
"integrity": "sha512-AWCCj1oNNpMK5hXfFo4JO5KyLhPrdBmpbA0wyIl4IUB05WTU0CGP2UgGX7Lcq/N8nS2+hcxzCPMSQ1R55FuMPg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/yellowtail": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/yellowtail/-/yellowtail-5.3.0.tgz",
|
||||
"integrity": "sha512-atz4CIdbP7otppxRaObJiygnkyJzIuLFpPRjWspXvXhBWLB0sk35cCu5r6gSkDomRtEWakJ8//bWbyBjJbilGg==",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
|
||||
@@ -173,7 +173,6 @@
|
||||
"client/src/pages/tripPlanner/plannerTypes.ts": 1,
|
||||
"client/src/pages/tripPlanner/useBookingEdits.ts": 1,
|
||||
"client/src/pages/tripPlanner/useDayPlanEdits.ts": 1,
|
||||
"client/src/pages/tripPlanner/usePlaceFormOpeners.ts": 1,
|
||||
"client/src/pages/tripPlanner/useRoadtripAlternatives.ts": 1,
|
||||
"client/src/pages/tripPlanner/useRoadtripStopEdits.ts": 1,
|
||||
"client/src/push/webPush.ts": 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user