Compare commits

..

24 Commits

Author SHA1 Message Date
Maurice 473132fc07 fix(studio): a slider commits the value it was moved to, and the paint render test uses a real shape
- The opacity and paint sliders remember the value they were last moved to and commit that when let go, instead of reading the controlled input, which still shows the old value until the parent renders.
- The render test's old-fade shape is star-5; star is not a shape, so the whole fixture failed to parse.
2026-10-23 21:07:04 +02:00
Maurice e7a5316990 test(studio): a raced Studio save carries its revision, as the Studio's own saves do 2026-10-23 20:58:19 +02:00
Maurice 670c841866 feat(studio): paint shapes and pages with gradients, alpha and twelve patterns
- A shape and a page take a paint: one colour with its own alpha, a linear or
  radial gradient of two to six stops, or one of twelve patterns (checks,
  gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales,
  sunburst, confetti). fill, gradient and background keep the colour that
  stands for the paint, so an older reader still draws something close, and a
  plain colour or the old cover fade is stored in the old fields alone.
- Gradients render as SVG in user space with ids per mounted copy, so the two
  halves of a spread in page mode never share a defs id; patterns are drawn
  out as geometry clipped to the outline and capped at 2000 primitives.
- The new paint picker has none, solid, gradient and pattern tabs, a stop bar
  to click, drag and pull stops off, an angle dial, a radial centre pad and
  pattern tiles drawn by the page's own generator.
- Every swatch row gains the book's own colours, colours read from the
  page's photos, recent colours and, where it applies, an opacity slider.
- With nothing selected the inspector sets the page background, gives it to
  every page of its kind, and lays a photo under the whole page.
- The assistant's background operation takes a paint as well as a colour,
  style and add set a shape's paint, and describe_book_page reads both.
- BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:36 +02:00
Maurice 338548bf7f test(studio): the assistant panel waits for its lazy chat as long as a busy runner needs
The chat is a lazy chunk that grew with the kept notes and the place picker;
findBy's default second ran out on the CI shard, so the welcome was never
found. The waits for it get ten seconds, under the fifteen second test limit.
2026-10-23 20:10:07 +02:00
Maurice a94464a953 fix(assistant): citedSources stays inside the Responses client 2026-10-23 19:13:51 +02:00
Maurice 88e6f332d3 fix(assistant): CI of the web search and tour write cards 2026-10-23 19:13:51 +02:00
Maurice d07b8ba56e feat(assistant): web search through the provider, and cards with a way back for the smaller tour writes
- Web search: ChatGPT (Responses web_search), Anthropic (web_search server
  tool) and OpenRouter (web plugin) search the web themselves when the admin
  switch allows it (on as it ships). A search shows as a step ("Searching the
  web for ..."), the cited pages as source chips under the answer. Anthropic
  answers that searched are replayed with their blocks (new provider state
  adapter); the search step never goes back to the model as a call. The
  prompt says when to search and that chosen places still go through
  search_place.
- Tour writes: assign_tour_track (undo: take that assignment off),
  unassign_tour_track, set_tour_activity_excluded (undo: the other switch),
  link_tour_packing and unlink_tour_packing (undo from the link before, which
  both answers now name) get change cards and their ways back.
2026-10-23 19:13:51 +02:00
Maurice e9f7d5e77b test(i18n): the plural test loads its locale itself instead of queueing behind every other
FE-COMP-I18NCTX-011a starts all 27 locale chunks at once and does not wait
for them, so the Russian chunk of FE-COMP-I18NCTX-018 queued behind the rest
and the test failed in the whole file (and on CI), though it passed alone.
2026-10-23 19:13:33 +02:00
Maurice 9a7c520106 test(i18n): a locale's first load gets 12 s on a busy CI shard 2026-10-23 18:21:54 +02:00
Maurice 5ccd4674ac fix(assistant): CI of the found places, kept notes and walk visits; a tour's answer shows no found places 2026-10-23 18:21:54 +02:00
Maurice d1b8cb20a3 feat(assistant): found places open the place form, notes kept on its own, city walk stops checked off
- Found places: the strip shows only when the answer names one of them, the
  named ones first. A click on a card, or "Add to trip", opens the planner's
  place form filled in with the place on the day in focus, its Google details
  (photos, hours, description) loading beside it; "+ Day" and the day grid
  are gone.
- Trip notes: remember_for_trip runs at once without a card. The assistant
  keeps lasting facts the user mentions (who travels, needs, likes, budget,
  pace) on its own and offers the unsure ones as a follow-up; the chat shows
  "Noted for this trip: ..." with the notes a tap away, and reads them again
  so the dialog never saves over the new note.
- City walks: set_city_walk_visit checks a station off on its day (visited,
  skipped, or the mark taken back), with day_edit as in the day plan, a
  change card naming the station and its walk, and an undo that restores the
  status before while the station is as the change left it.
- Card heads are a step smaller (32px tile, smaller title and count), and the
  dot beside "Working on it" in the header is gone.
2026-10-23 18:21:54 +02:00
Maurice dd2431db97 fix(tours): a hike over several days reaches the router without its stage ends
The router checks its request against the strict plan point, which has no
stage_end, so every hike with a stage end was refused with "Unrecognized
key: stage_end" before a way was planned. The points go to the router with
their position, name and kind only.
2026-10-23 17:13:08 +02:00
Maurice 746f53ff7e feat(assistant): the assistant plans hikes over several days in stages
- propose_hike, create_hike and update_tour take stage_end on the stops a day
  ends at, checked by the tours' own stage rules (never at the start or the
  end, at most 60 stages) on REST and MCP alike.
- A proposal over several days names its stage ends and sets no single day's
  schedule; its card counts the stages and marks the stops they end at.
- New points for a hike in stages keep the keys of the stops they stand on,
  so a stage keeps its day; they are refused only where they would drop every
  stage end.
- The tours group and the prompt name the stages, how to put each on its day,
  and what to offer once a tour is planned: places along it, its packing, the
  GPX file.
2026-10-23 16:47:05 +02:00
Maurice 7f9a4da52a fix(assistant): a tour in a change row runs the card's width with room above it, and the latest start is short
- The tour's sketch, figures and profile in a change row take the whole width
  of the card and sit apart from the row above (wideBody), instead of under
  its text pressed against it.
- "Start by 16:08" says the latest start in three words; the reason (to be
  back before sunset) is its tooltip.
2026-10-23 16:19:07 +02:00
Maurice 8043b7952d fix(assistant): the journal's strip of tiles says how many entries it holds under it again 2026-10-23 16:10:10 +02:00
Maurice e198392060 fix(assistant): no leftover from the journal's old foot, and the timeline test reads the walks where they are said 2026-10-23 15:57:48 +02:00
Maurice deea669e28 fix(assistant): the journal card's band says how many entries it holds again 2026-10-23 15:54:03 +02:00
Maurice d6b624e400 fix(assistant): one tap plans the tour for real, and a clear request gets a proposal
- The server joins a take to the answer that drew its proposal; the card only
  looked in later answers, so it never found the take to decide and "Plan
  this" still waited below for a second confirmation.
- An answer that asks the user something offers no fixed chips at all, only
  the model's own answers to its question.
- The model proposes a clear request at once, saying what it assumed, and
  puts the way it recommends first, which the card shows chosen.
2026-10-23 15:44:39 +02:00
Maurice 263d650460 chore(planner): keep TripPlannerPage at its size, the Tours map's new prop on an existing line 2026-10-23 15:38:31 +02:00
Maurice a3fc696510 feat(assistant): the chat's cards wear the trip dialogs' look
The cards were rows of badges in badges with no head of their own; they now
follow the frame #2541 gave the trip's dialogs and booking cards.
- Every answer card and the change card open on a tinted head band: a raised
  tile with the icon (a route its sketch), the kind as an eyebrow, the title,
  one quiet line and the count; the change card turns green once decided.
- Figures are framed fields under their labels (a route's distance, climb,
  descent, time), the parts of a card sit under eyebrows (height profile,
  stops), and the ways onward sit in a foot bar across the card.
- A route's stops are a timeline down a line, each walk on the line between
  two stops; the ways of a route are calm choices with their figures in one
  line and how they differ from the chosen one in another.
- Packing, to-dos, the journal, Vacay and the Atlas get the band; quantities
  and weights are quiet figures, an expense's place and split share one line.
2026-10-23 15:33:08 +02:00
Maurice 9f6f62c6ae feat(assistant): a proposed route shows on the Tours tab's map too, and its ways leave the map once one is planned
- The Tours tab's map draws the routes the chat's cards show and follows the
  assistant's camera, as the plan's map does; "On map" there stays on it and
  only a tab without a map switches to the plan's.
- Once one of an answer's ways is taken into the trip, the answer draws none of
  them: the taken one stands as its change, a discarded take brings them back.
2026-10-23 15:20:44 +02:00
Maurice a70ff750a6 test(studio): the typeface and display checks assert what they are about
BOOKQA-009 looks at font_mix alone (three one-text pages also repeat their
layout), and BOOKSTY-004 measures the display tier against the title the
classic style already sets (34 pt on this fixture, as on dev) instead of a
guessed size.
2026-10-23 14:20:24 +02:00
Maurice 815a523d1c feat(assistant): Studio's assistant may design lively books, not only restrained ones
- The design prompt keeps the print rules and adds energy: calm keeps today's
  rules, lively and loud layer photos, tilt, set oversize display type and
  block colour. Type sizes come as numbers relative to the page, and the model
  answers eight silent questions and previews the cover twice before it
  proposes.
- The type scale reaches 2.5 and a display tier sets a cover title up to
  200 pt; existing books keep their sizes.
- An overlap is severe only where more than half of a photo or a text is
  covered by accident; a title over a photo, a framed inset or a turned photo
  is layering.
- The assistant may use every icon of the icon tab, checked by name, and every
  enum of the book tools says what its values mean.
2026-10-23 14:15:01 +02:00
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00
338 changed files with 13950 additions and 1294 deletions
+1 -1
View File
@@ -102,7 +102,7 @@
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5,
@@ -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.' },
],
},
]);
+2 -18
View File
@@ -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
@@ -91,7 +91,7 @@ function SheetView({
height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides.
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
overflow: 'hidden',
}}
>
+26 -1
View File
@@ -15,13 +15,15 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry,
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -43,6 +45,24 @@ export function SaveIndicator({
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
@@ -86,3 +106,8 @@ export function SaveIndicator({
return null
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
}
+19 -5
View File
@@ -8,6 +8,9 @@ import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
import { usePaintId } from './paint/usePaintId'
/**
* One spread, drawn.
@@ -86,7 +89,8 @@ const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
*/
function ShapeView({ el }: { el: BookShapeElement }) {
const fill = el.fill ?? 'transparent'
const gradientId = `g-${el.id}`
// Per mounted copy: page mode draws a spread twice (paint/paintRender.tsx).
const gradientId = usePaintId(`g-${el.id}`)
const gradient = el.gradient !== 'none' && el.fill
if (el.shape === 'rect' || el.shape === 'ellipse') {
@@ -171,7 +175,7 @@ function PhotoView({ el, big, print, dropLabel }: {
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side)
const bottom = round2(deco.bottom * side)
const clipId = `c-${el.id}`
const clipId = usePaintId(`c-${el.id}`)
const clipped = el.mask && el.mask !== 'rect'
/*
@@ -260,7 +264,10 @@ function PhotoView({ el, big, print, dropLabel }: {
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
loading="lazy"
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
@@ -391,7 +398,9 @@ export function ElementView({
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
if (el.kind === 'shape') {
return el.paint ? <PaintedShapeView el={el} paint={el.paint} frameStyle={frameStyle(el)} /> : <ShapeView el={el} />
}
// Above the catch-all below, which routes everything that is not text into
// the travel elements and draws null for anything they do not know.
@@ -471,7 +480,8 @@ export function SpreadView({
style={{
position: 'absolute',
inset: 0,
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
// Under a paint the sheet is the paper, which a see-through paint lets show.
background: (spread.backgroundPaint ? null : spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
/*
* On screen the page ends at the trim, so anything past it is clipped.
* In print it must not be: the bleed is the part that runs off the edge
@@ -482,6 +492,10 @@ export function SpreadView({
overflow: print ? 'visible' : 'hidden',
}}
>
{spread.backgroundPaint && (
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
)}
{spread.elements.map(el => (
<ElementView
key={el.id}
@@ -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();
});
});
@@ -0,0 +1,130 @@
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { photoSrc } from './bookRender';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from './paint/backgroundPhoto';
import { spreadPhotoIds } from './paint/paletteModel';
import { usePaintChannel } from './paint/usePaintChannel';
import { Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { JourneySource, StudioT } from './StudioSidebar';
/**
* The page's background, in the inspector when nothing is selected.
*
* Until now only the assistant could set it; a person had to lay a shape
* across the page and remember to send it to the back. Three things, in the
* order people reach for them: the paint itself, the same background for
* every page, and a photograph under the whole page.
*/
export function StudioBackgroundSection({
spreadIndex,
page,
source,
t,
}: {
spreadIndex: number;
page: BookPageSetup;
source: JourneySource;
t: StudioT;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit);
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
}));
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
const [applied, setApplied] = useState(false);
useEffect(() => {
if (!applied) return;
const timer = window.setTimeout(() => setApplied(false), 1600);
return () => window.clearTimeout(timer);
}, [applied]);
const current = backgroundPhotoOf(spread, page);
const candidates = useMemo(() => {
if (!spread) return [];
const entry = source.entries.find((e) => e.id === spread.entryId);
return backgroundCandidates(
spreadPhotoIds(spread),
entry?.photoIds ?? [],
source.photos.map((p) => p.photoId)
);
}, [spread, source]);
if (!spread) return null;
const setPhoto = (photoId: number) =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp;
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
}),
}));
const clearPhoto = () =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
}));
const applyToAll = () => {
commit((doc) => backgroundToAll(doc, spreadIndex));
setApplied(true);
};
return (
<Section label={t('journey.studio.paint.background')}>
<StudioPaintPicker
value={spreadPaint(spread)}
channel={channel}
t={t}
noneLabel={t('journey.studio.paint.paper')}
/>
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
</button>
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
{candidates.length ? (
<>
<div className="st-bgphoto-grid">
{candidates.map((id) => (
<button
type="button"
key={id}
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
aria-pressed={current?.photoId === id}
aria-label={t('journey.studio.paint.photoBackground')}
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
>
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
</div>
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
{current && (
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
<ImageOff size={13} />
{t('journey.studio.paint.removePhotoBackground')}
</button>
)}
</>
) : (
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
)}
</div>
</Section>
);
}
@@ -10,6 +10,8 @@ import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
import { formatBookCoords } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
@@ -100,6 +102,8 @@ export function StudioInspector({
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
@@ -515,54 +519,13 @@ export function StudioInspector({
</div>
</Section>
<Section label={t('journey.studio.colour')}>
{/*
Fill and stroke, both named. The panel used to show one
unlabelled swatch row that wrote `fill`, so on an outline-only
shape it lit up a colour that was not being drawn and there was
no way to reach the line you could actually see.
*/}
<Line label={t('journey.studio.fill')} wide>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches
value={el.stroke ?? '#141414'}
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.fillOn')}
on={el.fill !== null}
onToggle={() => set({
// Turning the fill off on a shape with no stroke would make
// it invisible, so the outline comes on with it.
fill: el.fill === null ? '#111111' : null,
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
})}
/>
<Switch
label={t('journey.studio.gradient')}
on={el.gradient !== 'none'}
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
/>
</div>
{el.gradient !== 'none' && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.gradient')}>
<Choice
value={el.gradient}
options={[
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
]}
onPick={gradient => set({ gradient })}
/>
</Line>
</div>
)}
</Section>
<StudioShapePaint
el={el}
spreadIndex={spreadIndex}
selection={selection}
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
t={t}
/>
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
@@ -0,0 +1,371 @@
import {
PATTERN_SCALE,
RADIAL_RADIUS,
stopAlpha,
type BookGradientPaint,
type BookPaint,
type BookPattern,
type BookPatternPaint,
} from '@trek/shared';
import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react';
import { useRef, useState } from 'react';
import '../../styles/studio-paint.css';
import { NumField } from './StudioControls';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { PatternSwatches } from './paint/PatternSwatches';
import { RadialPad } from './paint/RadialPad';
import {
PATTERN_ANGLE,
clampRadius,
clampScale,
cssPaint,
gradientAs,
kindOf,
normAngle,
paintAs,
removeStop,
reverseStops,
updateStop,
type PaintKind,
} from './paint/paintEdit';
import type { PaintChannel } from './paint/usePaintChannel';
/**
* Paint, picked: none, one colour, a gradient or a pattern.
*
* ── How it is laid out ───────────────────────────────────────────────────
*
* One row of four tabs says what kind of paint it is, each with a swatch of
* what choosing it gives, and below it only the controls that kind has. The
* tab is the question people ask first ("flat or a gradient?"), so it is a
* row they can read at a glance rather than a menu.
*
* Switching kind keeps the colour: a solid becomes a gradient from that
* colour to a lighter partner, a gradient becomes a pattern in its first
* colour, and coming back to a kind already visited in this session brings
* it back as it was left, so trying a pattern costs nothing.
*
* Every drag (a stop, the dial, the centre, a slider) writes live and is one
* undo step when it is let go, through the channel the caller hands in.
*/
const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern'];
/** A kind's name on its tab. */
function kindLabel(k: Exclude<PaintKind, 'none'>, t: StudioT): string {
if (k === 'solid') return t('journey.studio.paint.solid');
return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern');
}
export function StudioPaintPicker({
value,
channel,
t,
noneLabel,
}: {
value: BookPaint | null;
channel: PaintChannel;
t: StudioT;
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
noneLabel: string;
}) {
const kind = kindOf(value);
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
const [memory, setMemory] = useState<Partial<Record<PaintKind, BookPaint>>>({});
const [stop, setStop] = useState(0);
const write = (paint: BookPaint | null, done = true) => {
const k = kindOf(paint);
if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint }));
if (done) channel.commit(paint);
else channel.live(paint);
};
const choose = (next: PaintKind) => {
if (next === kind) return;
const kept = value ? { ...memory, [kind]: value } : memory;
if (value) setMemory((m) => ({ ...m, [kind]: value }));
setStop(0);
write(paintAs(next, value, kept));
};
const tabPreview = (k: PaintKind) =>
k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory));
return (
<div className="st-paint">
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
{KINDS.map((k) => (
<button
type="button"
key={k}
role="tab"
aria-selected={k === kind}
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
onClick={() => choose(k)}
>
<span
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
aria-hidden
>
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
</span>
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
</button>
))}
</div>
{value?.type === 'solid' && (
<Swatches
value={value.color}
onPick={(color) => write({ ...value, color })}
alpha={stopAlpha(value)}
onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)}
/>
)}
{(value?.type === 'linear' || value?.type === 'radial') && (
<GradientControls
paint={value}
stop={Math.min(stop, value.stops.length - 1)}
onStop={setStop}
write={write}
t={t}
/>
)}
{value?.type === 'pattern' && <PatternControls paint={value} write={write} t={t} />}
</div>
);
}
const withAlpha = <T extends { alpha?: number }>(o: T, alpha: number): T => {
const { alpha: _old, ...rest } = o;
return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T;
};
type Write = (paint: BookPaint | null, done?: boolean) => void;
function GradientControls({
paint,
stop,
onStop,
write,
t,
}: {
paint: BookGradientPaint;
stop: number;
onStop: (i: number) => void;
write: Write;
t: StudioT;
}) {
const current = paint.stops[stop];
const canRemove = paint.stops.length > 2;
return (
<div className="st-paint-body">
<div className="st-paint-row">
<div className="st-seg" role="radiogroup" aria-label={t('journey.studio.paint.gradient')}>
{(['linear', 'radial'] as const).map((type) => (
<button
type="button"
key={type}
role="radio"
aria-checked={paint.type === type}
className={`st-seg-item ${paint.type === type ? 'is-on' : ''}`}
onClick={() => write(gradientAs(paint, type))}
>
{type === 'linear' ? t('journey.studio.paint.linear') : t('journey.studio.paint.radial')}
</button>
))}
</div>
<button
type="button"
className="st-chip is-icon"
title={t('journey.studio.paint.reverse')}
aria-label={t('journey.studio.paint.reverse')}
onClick={() => write(reverseStops(paint))}
>
<ArrowLeftRight size={13} />
</button>
</div>
<GradientStopBar
paint={paint}
selected={stop}
onSelect={onStop}
onChange={(p, done) => write(p, done)}
labels={{
bar: t('journey.studio.paint.addStopHint'),
stop: (pos) => t('journey.studio.paint.stopAt', { pos }),
}}
/>
{current && (
<div className="st-paint-stop">
<div className="st-paint-row">
<span className="st-paint-caption">
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
</span>
<button
type="button"
className="st-chip is-icon is-danger"
disabled={!canRemove}
title={t('journey.studio.paint.removeStop')}
aria-label={t('journey.studio.paint.removeStop')}
onClick={() => {
onStop(Math.max(0, stop - 1));
write(removeStop(paint, stop));
}}
>
<Trash2 size={13} />
</button>
</div>
<Swatches
value={current.color}
onPick={(color) => write(updateStop(paint, stop, { color }))}
alpha={stopAlpha(current)}
onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)}
/>
</div>
)}
{paint.type === 'linear' ? (
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<NumField
label={t('journey.studio.paint.angle')}
value={normAngle(paint.angle)}
min={0}
max={359}
step={1}
unit="°"
onChange={(angle) => write({ ...paint, angle: normAngle(angle) })}
/>
</div>
) : (
<div className="st-paint-row is-dial">
<RadialPad paint={paint} label={t('journey.studio.paint.centre')} onChange={(p, done) => write(p, done)} />
<Slider
label={t('journey.studio.paint.radius')}
value={paint.r}
min={RADIAL_RADIUS.min}
max={RADIAL_RADIUS.max}
shown={`${Math.round(paint.r * 100)}%`}
onChange={(r, done) => write({ ...paint, r: clampRadius(r) }, done)}
/>
</div>
)}
</div>
);
}
function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) {
const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle });
return (
<div className="st-paint-body">
<PatternSwatches value={paint} onPick={pick} label={(p) => t(`journey.studio.pattern.${p}`)} />
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.ink')}</span>
<Swatches value={paint.fg} onPick={(fg) => write({ ...paint, fg })} />
</div>
<div className="st-paint-field">
<div className="st-paint-row">
<span className="st-paint-caption">{t('journey.studio.paint.ground')}</span>
<button
type="button"
role="switch"
aria-checked={paint.bg === null}
className={`st-chip is-pick ${paint.bg === null ? 'is-on' : ''}`}
onClick={() => write({ ...paint, bg: paint.bg === null ? '#ffffff' : null })}
>
{t('journey.studio.paint.seeThrough')}
</button>
</div>
{paint.bg !== null && <Swatches value={paint.bg} onPick={(bg) => write({ ...paint, bg })} />}
</div>
<div className="st-paint-row is-dial">
<AngleDial
value={paint.angle}
label={t('journey.studio.paint.angle')}
onChange={(angle, done) => write({ ...paint, angle }, done)}
/>
<Slider
label={t('journey.studio.paint.scale')}
value={paint.scale}
min={PATTERN_SCALE.min}
max={PATTERN_SCALE.max}
shown={`${Math.round(paint.scale * 100)}%`}
log
onChange={(scale, done) => write({ ...paint, scale: clampScale(scale) }, done)}
/>
</div>
</div>
);
}
/** A labelled slider with its value beside it; `done` as for the dial. */
function Slider({
label,
value,
min,
max,
shown,
onChange,
log = false,
}: {
label: string;
value: number;
min: number;
max: number;
shown: string;
onChange: (v: number, done: boolean) => void;
/** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */
log?: boolean;
}) {
const steps = 100;
const toValue = (raw: string) => {
const t = Number(raw) / steps;
return log ? min * (max / min) ** t : min + (max - min) * t;
};
const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps);
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value);
return (
<label className="st-paint-slider">
<span className="st-paint-slider-head">
<span className="st-paint-caption">{label}</span>
<span className="st-paint-value">{shown}</span>
</span>
<input
type="range"
min={0}
max={steps}
step={1}
value={pos}
onPointerDown={() => {
moved.current = value;
}}
onKeyDown={() => {
moved.current = value;
}}
onChange={(e) => {
moved.current = toValue(e.target.value);
onChange(moved.current, false);
}}
onPointerUp={() => onChange(moved.current, true)}
onKeyUp={() => onChange(moved.current, true)}
/>
</label>
);
}
@@ -0,0 +1,60 @@
import { shapePaint, withShapePaint, type BookElement, type BookShapeElement } from '@trek/shared';
import { usePaintChannel } from './paint/usePaintChannel';
import { Line, Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { StudioT } from './StudioSidebar';
import { Swatches } from './StudioSwatches';
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
const OUTLINE = '#141414';
/**
* A shape's colours: its fill as a paint, and its outline.
*
* The fill goes to every selected shape at once, the way the rest of the
* inspector's properties do. Taking the fill away from a shape with no
* outline gives it one, as the old fill switch did: a shape with neither is
* a shape nobody can see or find again.
*/
export function StudioShapePaint({
el,
spreadIndex,
selection,
onStroke,
t,
}: {
el: BookShapeElement;
spreadIndex: number;
selection: readonly string[];
onStroke: (color: string) => void;
t: StudioT;
}) {
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) =>
i !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((e): BookElement => {
if (e.kind !== 'shape' || !selection.includes(e.id)) return e;
const painted = withShapePaint(e, paint);
return paint === null && !e.stroke
? { ...painted, stroke: OUTLINE, strokeWidth: e.strokeWidth || 0.5 }
: painted;
}),
}
),
}));
return (
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.fill')} wide>
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
</Line>
</Section>
);
}
+13 -10
View File
@@ -13,6 +13,7 @@ import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
import { useAuthStore } from '../../store/authStore'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioPaletteScope } from './paint/StudioPalette'
import { StudioWordmark } from './StudioWordmark'
import { SaveIndicator } from './SaveIndicator'
import { downloadSpread } from './spreadFile'
@@ -124,16 +125,18 @@ export default function StudioShell() {
onBrowseProvider={s.browseProvider}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
path={s.path}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
path={s.path}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
</StudioPaletteScope>
</div>
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} bookVersion={s.bookVersion} />
<StudioPageStage assistant={assistant} />
+121 -15
View File
@@ -1,3 +1,8 @@
import { useRef } from 'react'
import { BookOpen, History, Image } from 'lucide-react'
import { useStudioPalette } from './paint/paletteModel'
import '../../styles/studio-paint.css'
/**
* The colour picker, shared by everything in the inspector that has a colour.
*
@@ -8,6 +13,12 @@
* The palette is print ink rather than app chrome — it does not follow the
* user's theme, because a book is printed once and read on paper, where an
* accent chosen for a dark interface means nothing.
*
* Under the fixed inks sit up to three short rows from the open book
* (paint/StudioPalette.tsx): the colours the book already uses, the colours
* of the photos on this page, and what this person picked lately. Each row
* is there only when it has something in it, so a new book shows the inks
* alone, as before.
*/
const SWATCHES = [
@@ -16,23 +27,118 @@ const SWATCHES = [
'#1e3a8a', '#9f1239', '#4c1d95',
]
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
function Swatch({ color, on, onPick }: { color: string; on: boolean; onPick: (c: string) => void }) {
return (
<div className="st-swatches">
{SWATCHES.map(c => (
<button type="button"
key={c}
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
onClick={() => onPick(c)}
aria-label={c}
/>
<button type="button"
className={`st-swatch ${on ? 'is-on' : ''}`}
style={{ background: color }} // theme-lint-disable: a colour picker shows the colours themselves
onClick={() => onPick(color)}
aria-label={color}
aria-pressed={on}
/>
)
}
export function Swatches({ value, onPick, alpha, onAlpha, rows = true }: {
value: string
onPick: (c: string) => void
/** With `onAlpha`, an opacity slider under the swatches, 0 to 1. */
alpha?: number
/** `done` is false while the slider is dragged and true when it is let go. */
onAlpha?: (a: number, done: boolean) => void
/** The book, photo and recent rows; off where only the fixed inks make sense. */
rows?: boolean
}) {
const palette = useStudioPalette()
const current = value.toLowerCase()
// The wheel reports every colour it passes through; only the one it stops on is worth remembering.
const wheel = useRef<string | null>(null)
const pick = (c: string) => {
palette?.remember(c)
onPick(c)
}
const groups = rows && palette
? ([
['book', BookOpen, palette.book],
['photos', Image, palette.photos],
['recent', History, palette.recent],
] as const).filter(([, , list]) => list.length > 0)
: []
return (
<div className="st-swatch-stack">
<div className="st-swatches">
{SWATCHES.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
{/* Named by the colour it holds, the way the preset swatches above are. */}
<input
type="color"
aria-label={value}
value={value}
onChange={e => { wheel.current = e.target.value; onPick(e.target.value) }}
onBlur={() => { if (wheel.current) palette?.remember(wheel.current); wheel.current = null }}
/>
</label>
</div>
{groups.map(([key, Icon, list]) => (
<div key={key} className="st-swatch-row" role="group" aria-label={palette?.labels[key]}>
<span className="st-swatch-row-icon" title={palette?.labels[key]} aria-hidden>
<Icon size={12} strokeWidth={2} />
</span>
<div className="st-swatches">
{list.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
</div>
</div>
))}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
{/* Named by the colour it holds, the way the preset swatches above are. */}
<input type="color" aria-label={value} value={value} onChange={e => onPick(e.target.value)} />
</label>
{onAlpha && <AlphaSlider color={value} value={alpha ?? 1} onChange={onAlpha} label={palette?.labels.opacity} />}
</div>
)
}
/**
* How opaque the colour is, as a slider over the colour itself.
*
* The track runs from nothing to the full colour over a chequerboard, so the
* slider shows what it does rather than needing a label to explain it, and
* the figure beside it says the exact value for anyone matching a reference.
*/
export function AlphaSlider({ color, value, onChange, label }: {
color: string
value: number
onChange: (a: number, done: boolean) => void
/** The slider's accessible name, in the reader's language. */
label?: string
}) {
const pct = Math.round(value * 100)
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
const moved = useRef(value)
const move = (raw: string) => {
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
onChange(moved.current, false)
}
const finish = () => onChange(moved.current, true)
return (
<div className="st-alpha">
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
<input
type="range"
min={0}
max={100}
step={1}
value={pct}
aria-label={label}
onPointerDown={() => { moved.current = value }}
onKeyDown={() => { moved.current = value }}
onChange={e => move(e.target.value)}
onPointerUp={finish}
onKeyUp={finish}
/>
</div>
<span className="st-alpha-value">{pct}%</span>
</div>
)
}
@@ -0,0 +1,29 @@
/**
* FE-STUDIO-ICON-001 to FE-STUDIO-ICON-002
*
* The icons the book engine takes (BOOK_ICON_NAMES in @trek/shared) are the
* drawings the Studio's icon tab offers, no more and no fewer: one the engine
* took that this lucide does not have would print as the fallback glyph, and
* one the tab offers that the engine refused would be a drawing a person can
* place and the assistant cannot. When lucide-react moves, run
* `node scripts/gen-book-icon-names.mjs` in shared/ and this passes again.
*/
import { BOOK_DESIGN_ICONS, BOOK_ICON_NAMES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { FEATURED_ICONS, ICON_NAMES } from './iconLibrary';
describe('the icons the book engine takes', () => {
it('FE-STUDIO-ICON-001: are every drawing the icon tab offers, and nothing it does not', () => {
// `Icon` is the component every drawing is built on, not a drawing.
const offered = ICON_NAMES.filter((name) => name !== 'Icon');
const missing = offered.filter((name) => !BOOK_ICON_NAMES.has(name));
const extra = [...BOOK_ICON_NAMES].filter((name) => !offered.includes(name));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
});
it('FE-STUDIO-ICON-002: hold the whole travel shelf the tab opens on', () => {
expect(BOOK_DESIGN_ICONS.filter((name) => !BOOK_ICON_NAMES.has(name))).toEqual([]);
expect(FEATURED_ICONS).toEqual([...BOOK_DESIGN_ICONS]);
});
});
+4 -3
View File
@@ -60,9 +60,10 @@ export const ICON_NAMES: string[] = buildNames()
* of them quietly drops it instead of drawing a fallback in the shelf.
*/
/*
* The shelf itself lives in @trek/shared (book-icons.ts), because the book
* engine places icons from the same list: the assistant may only name a
* drawing a person could pick here.
* The shelf itself lives in @trek/shared (book-icons.ts), beside the whole
* list the book engine takes (BOOK_ICON_NAMES): the assistant may name any
* drawing a person could pick here, and iconLibrary.test.ts holds that list
* to ICON_NAMES.
*/
const FEATURED: readonly string[] = BOOK_DESIGN_ICONS
@@ -0,0 +1,91 @@
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
import { dialAngle, normAngle } from './paintEdit';
/**
* An angle, set by turning a dial.
*
* A number field alone asks people to know that a gradient running to the
* bottom right is 135 degrees; a dial lets them point at it. It snaps to the
* eight compass angles when the pointer passes close to one (Alt turns the
* snap off), and takes the arrow keys, Shift for steps of fifteen.
*
* `done` is false while the dial is turned and true on the value it is let go
* on, so the turn is one undo step (usePaintChannel.ts).
*/
export function AngleDial({
value,
onChange,
label,
}: {
value: number;
onChange: (angle: number, done: boolean) => void;
label: string;
}) {
const dial = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const last = useRef(value);
const read = (e: PointerEvent<HTMLDivElement>) => {
const box = dial.current?.getBoundingClientRect();
if (!box) return last.current;
return dialAngle(e.clientX - (box.left + box.width / 2), e.clientY - (box.top + box.height / 2), e.altKey);
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragging.current = true;
last.current = read(e);
onChange(last.current, false);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const a = read(e);
if (a === last.current) return;
last.current = a;
onChange(a, false);
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
onChange(last.current, true);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 15 : 1;
const by =
e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step : 0;
if (!by) return;
e.preventDefault();
last.current = normAngle(value + by);
onChange(last.current, false);
};
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key.startsWith('Arrow')) onChange(last.current, true);
};
return (
<div
ref={dial}
className="st-dial"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={359}
aria-valuenow={normAngle(value)}
aria-valuetext={`${normAngle(value)}°`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
<span className="st-dial-needle" style={{ transform: `rotate(${normAngle(value)}deg)` }} aria-hidden>
<span className="st-dial-knob" />
</span>
</div>
);
}
@@ -0,0 +1,129 @@
import type { BookGradientPaint } from '@trek/shared';
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { addStop, cssStops, removeStop, updateStop } from './paintEdit';
/**
* The stops of a gradient, on a bar that shows the gradient itself.
*
* Click the bar to add a stop there, in the colour the gradient already has
* at that point, so adding one changes nothing until it is moved or recoloured.
* Drag a stop along the bar to move it, or pull it off the bar downwards
* (it fades) and let go to take it out; the arrow keys move the chosen one, Delete removes it. A
* gradient keeps at least two stops, so the last two cannot be pulled off.
*
* The bar runs left to right in every language, like the gradient preview
* it carries, so it sets its own direction (studio-paint.css).
*/
/** How far below the bar a stop is pulled before letting go removes it, in pixels. */
const PULL_OFF = 28;
export function GradientStopBar({
paint,
selected,
onSelect,
onChange,
labels,
}: {
paint: BookGradientPaint;
selected: number;
onSelect: (index: number) => void;
onChange: (paint: BookGradientPaint, done: boolean) => void;
labels: { bar: string; stop: (pos: number) => string };
}) {
const bar = useRef<HTMLDivElement>(null);
const drag = useRef<{ index: number; moved: boolean; off: boolean; paint: BookGradientPaint } | null>(null);
// The stop being pulled off, drawn faded until it is let go, when it goes.
const [leaving, setLeaving] = useState<number | null>(null);
const canRemove = paint.stops.length > 2;
const posOf = (clientX: number) => {
const box = bar.current?.getBoundingClientRect();
if (!box || box.width <= 0) return 0;
return (clientX - box.left) / box.width;
};
const onBarDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || e.target !== e.currentTarget) return;
const added = addStop(paint, posOf(e.clientX));
if (!added) return;
onSelect(added.index);
onChange(added.paint, true);
};
const onStopDown = (index: number) => (e: PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
onSelect(index);
drag.current = { index, moved: false, off: false, paint };
};
const onStopMove = (e: PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d) return;
const box = bar.current?.getBoundingClientRect();
const off = canRemove && !!box && e.clientY > box.bottom + PULL_OFF;
const next = updateStop(d.paint, d.index, { at: posOf(e.clientX) });
drag.current = { ...d, moved: true, off, paint: next };
setLeaving(off ? d.index : null);
onChange(next, false);
};
const onStopUp = () => {
const d = drag.current;
drag.current = null;
setLeaving(null);
if (!d?.moved) return;
if (d.off) {
onSelect(Math.max(0, d.index - 1));
onChange(removeStop(d.paint, d.index), true);
return;
}
onChange(d.paint, true);
};
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const stop = paint.stops[index];
if (!stop) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
e.preventDefault();
onSelect(Math.max(0, index - 1));
onChange(removeStop(paint, index), true);
return;
}
const step = e.shiftKey ? 0.1 : 0.01;
const by = e.key === 'ArrowRight' ? step : e.key === 'ArrowLeft' ? -step : 0;
if (!by) return;
e.preventDefault();
onChange(updateStop(paint, index, { at: stop.at + by }), true);
};
return (
<div className="st-stopbar-wrap">
<div ref={bar} className="st-stopbar" title={labels.bar} aria-label={labels.bar} onPointerDown={onBarDown}>
<span
className="st-stopbar-fill"
style={{ backgroundImage: `linear-gradient(90deg, ${cssStops(paint.stops)})` }}
aria-hidden
/>
{paint.stops.map((stop, i) => (
<button
type="button"
// The index is the stop: two stops may share a colour and a position.
key={i}
className={`st-stop ${i === selected ? 'is-on' : ''} ${leaving === i ? 'is-leaving' : ''}`}
style={{ ['--at' as string]: `${Math.round(stop.at * 1000) / 10}%`, ['--stop' as string]: stop.color }}
aria-label={labels.stop(Math.round(stop.at * 100))}
aria-pressed={i === selected}
onPointerDown={onStopDown(i)}
onPointerMove={onStopMove}
onPointerUp={onStopUp}
onPointerCancel={onStopUp}
onKeyDown={onStopKey(i)}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import type { BookPaint, BookShapeElement } from '@trek/shared';
import { useMemo, type CSSProperties } from 'react';
import { HOLED_SHAPES } from '../shapes';
import { shapeOutline, strokeDash } from './paintGeometry';
import { PaintedPath } from './paintRender';
/**
* A shape that carries a paint, drawn wholly in SVG.
*
* The shapes from before paint stay as SpreadView draws them, the rectangle
* and the ellipse as CSS boxes, so no book that exists changes by a pixel.
* A painted shape is one path whatever its kind: a gradient and a pattern
* need an outline to lie over and to be cut to, and a rectangle with a radius
* is as much an outline as a heart is.
*
* As in SpreadView, the path is drawn into the frame inset by half the
* stroke, so the whole stroke sits inside the frame the editor snapped.
*/
export function PaintedShapeView({
el,
paint,
frameStyle,
}: {
el: BookShapeElement;
paint: BookPaint;
frameStyle: CSSProperties;
}) {
const sw = el.stroke ? el.strokeWidth : 0;
const w = Math.max(0.01, el.frame.w - sw);
const h = Math.max(0.01, el.frame.h - sw);
const d = useMemo(() => shapeOutline(el, w, h, sw / 2), [el, w, h, sw]);
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
return (
<div style={frameStyle}>
<svg
width={`${el.frame.w}mm`}
height={`${el.frame.h}mm`}
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
style={{ display: 'block', overflow: 'visible' }}
>
<g transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}>
<PaintedPath
d={d}
paint={paint}
box={box}
idPrefix={el.id}
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
stroke={el.stroke ?? undefined}
strokeWidth={sw || undefined}
strokeDasharray={strokeDash(el.strokeStyle, sw)}
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
strokeLinejoin="round"
/>
</g>
</svg>
</div>
);
}
@@ -0,0 +1,61 @@
import { BOOK_PATTERNS, type BookPattern, type BookPatternPaint } from '@trek/shared';
import { useMemo } from 'react';
import { PATTERN_ANGLE } from './paintEdit';
import { patternGeometry } from './patterns';
/**
* The twelve repeats, each drawn as a tile in the paint's own colours.
*
* Drawn by the same generator the page uses, at a smaller scale, so a tile
* is a true preview of what choosing it does rather than an icon of it, and
* a repeat that looks wrong in the tile looks wrong on the page too.
*/
const TILE = 24;
function PatternTile({ pattern, fg, bg }: { pattern: BookPattern; fg: string; bg: string | null }) {
const geo = useMemo(
() =>
patternGeometry({ pattern, scale: 0.42, angle: PATTERN_ANGLE[pattern] ?? 0 }, { x: 0, y: 0, w: TILE, h: TILE }),
[pattern]
);
return (
<svg viewBox={`0 0 ${TILE} ${TILE}`} className="st-pattern-tile-art" aria-hidden>
{bg && <rect width={TILE} height={TILE} fill={bg} />}
<g transform={geo.transform}>
{geo.layers.map((layer, i) => (
<path key={i} d={layer.d} fill={fg} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
))}
</g>
</svg>
);
}
export function PatternSwatches({
value,
onPick,
label,
}: {
value: BookPatternPaint;
onPick: (pattern: BookPattern) => void;
label: (pattern: BookPattern) => string;
}) {
return (
<div className="st-pattern-grid" role="radiogroup">
{BOOK_PATTERNS.map((pattern) => (
<button
type="button"
key={pattern}
role="radio"
aria-checked={value.pattern === pattern}
aria-label={label(pattern)}
title={label(pattern)}
className={`st-pattern-tile ${value.pattern === pattern ? 'is-on' : ''} ${value.bg ? '' : 'is-clear'}`}
onClick={() => onPick(pattern)}
>
<PatternTile pattern={pattern} fg={value.fg} bg={value.bg} />
</button>
))}
</div>
);
}
@@ -0,0 +1,86 @@
import type { BookGradientPaint } from '@trek/shared';
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
import { clampUnit, cssStops } from './paintEdit';
/**
* Where a radial gradient starts: a small preview of it with its centre on
* it, to drag. The preview is the gradient itself, so the point being moved
* is the bright spot or the dark one people are actually placing.
*
* Like the stop bar it is a picture, laid out left to right in every
* language (studio-paint.css), with the arrow keys for a step of 2 %.
*/
export function RadialPad({
paint,
onChange,
label,
}: {
paint: Extract<BookGradientPaint, { type: 'radial' }>;
onChange: (paint: BookGradientPaint, done: boolean) => void;
label: string;
}) {
const pad = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const last = useRef(paint);
const at = (e: PointerEvent<HTMLDivElement>) => {
const box = pad.current?.getBoundingClientRect();
if (!box || box.width <= 0 || box.height <= 0) return paint;
return {
...paint,
cx: clampUnit((e.clientX - box.left) / box.width),
cy: clampUnit((e.clientY - box.top) / box.height),
};
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
dragging.current = true;
last.current = at(e);
onChange(last.current, false);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
last.current = at(e);
onChange(last.current, false);
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
onChange(last.current, true);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dx = e.key === 'ArrowRight' ? 0.02 : e.key === 'ArrowLeft' ? -0.02 : 0;
const dy = e.key === 'ArrowDown' ? 0.02 : e.key === 'ArrowUp' ? -0.02 : 0;
if (!dx && !dy) return;
e.preventDefault();
onChange({ ...paint, cx: clampUnit(paint.cx + dx), cy: clampUnit(paint.cy + dy) }, true);
};
return (
<div
ref={pad}
className="st-radial-pad"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuetext={`${Math.round(paint.cx * 100)}% ${Math.round(paint.cy * 100)}%`}
style={{
backgroundImage: `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
<span
className="st-radial-dot"
style={{ ['--cx' as string]: `${paint.cx * 100}%`, ['--cy' as string]: `${paint.cy * 100}%` }}
aria-hidden
/>
</div>
);
}
@@ -0,0 +1,101 @@
import { bookColours, type BookDocument, type BookSpread, type PaletteSwatch } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useStudioStore } from '../../../store/studioStore';
import { photoSrc } from '../bookRender';
import {
StudioPaletteContext,
readRecent,
spreadPhotoIds,
withRecent,
writeRecent,
type StudioPaletteValue,
} from './paletteModel';
import { photoSwatches, readPhotoPalette } from './photoPalette';
/**
* The colours every swatch row in the inspector offers beside its fixed inks:
* the book's own, the ones in the page's photographs, and the ones this
* person picked lately.
*
* A context rather than props, because the swatch rows sit at every depth of
* the inspector, the travel elements' panels included, and each of them
* should offer the same three rows without every caller passing them down.
*/
/** The palettes of the page's photos, read once per photo for as long as the inspector is open. */
function usePhotoColours(photoIds: readonly number[]): string[] {
const [read, setRead] = useState<ReadonlyMap<number, PaletteSwatch[]>>(() => new Map());
// The ids travel as one string, so a new array holding the same photos reads nothing again.
const key = photoIds.join(',');
useEffect(() => {
const ids = key ? key.split(',').map(Number) : [];
const missing = ids.filter((id) => !read.has(id));
if (!missing.length) return;
const ctrl = new AbortController();
void Promise.all(
missing.map(async (id) => [id, await readPhotoPalette(photoSrc(id, false), ctrl.signal)] as const)
).then((found) => {
if (ctrl.signal.aborted) return;
setRead((prev) => new Map([...prev, ...found]));
});
return () => ctrl.abort();
}, [key, read]);
return useMemo(() => {
const ids = key ? key.split(',').map(Number) : [];
return photoSwatches(ids.map((id) => read.get(id) ?? []));
}, [key, read]);
}
export function StudioPaletteProvider({
doc,
spread,
labels,
children,
}: {
doc: BookDocument | null;
spread: BookSpread | undefined;
labels: StudioPaletteValue['labels'];
children: ReactNode;
}) {
const book = useMemo(() => bookColours(doc), [doc]);
const photos = usePhotoColours(useMemo(() => spreadPhotoIds(spread), [spread]));
const [recent, setRecent] = useState(readRecent);
const remember = useCallback((hex: string) => {
setRecent((list) => {
const next = withRecent(list, hex);
writeRecent(next);
return next;
});
}, []);
const value = useMemo(() => ({ book, photos, recent, remember, labels }), [book, photos, recent, remember, labels]);
return <StudioPaletteContext.Provider value={value}>{children}</StudioPaletteContext.Provider>;
}
/** The provider for the open book: the inspector of the page being edited sits inside it. */
export function StudioPaletteScope({
spreadIndex,
t,
children,
}: {
spreadIndex: number;
t: (k: string) => string;
children: ReactNode;
}) {
const doc = useStudioStore((s) => s.doc);
const labels = useMemo(
() => ({
book: t('journey.studio.paint.bookColours'),
photos: t('journey.studio.paint.photoColours'),
recent: t('journey.studio.paint.recentColours'),
opacity: t('journey.studio.paint.opacity'),
}),
[t]
);
return (
<StudioPaletteProvider doc={doc} spread={doc?.spreads[spreadIndex]} labels={labels}>
{children}
</StudioPaletteProvider>
);
}
@@ -0,0 +1,105 @@
import {
MAX_SPREAD_ELEMENTS,
bookPhotoElementSchema,
spreadPaint,
withSpreadPaint,
type BookDocument,
type BookElement,
type BookPageSetup,
type BookPhotoElement,
type BookSpread,
} from '@trek/shared';
/**
* The page background's two moves beyond its paint: a photo laid under the
* whole page, and one page's background given to every page.
*
* A background photo is an ordinary photo element, not a field of the page:
* full bleed across the spread, at the back, and locked, so it does not come
* away under a click meant for what is on top of it. Being an element, it
* prints, crops, filters and undoes like every other picture, and the
* assistant reaches it with the operations it already has (add, fill, lock).
*/
/** The frame of a photo that covers the whole sheet, bleed included. */
export function bleedFrame(spread: BookSpread, page: BookPageSetup) {
const pages = spread.role === 'inner' ? 2 : 1;
return {
x: -page.bleed,
y: -page.bleed,
w: page.pageWidth * pages + page.bleed * 2,
h: page.pageHeight + page.bleed * 2,
};
}
const near = (a: number, b: number) => Math.abs(a - b) < 0.6;
/** The page's background photo: the locked picture at the back that covers the whole sheet. Null when it has none. */
export function backgroundPhotoOf(spread: BookSpread | undefined, page: BookPageSetup): BookPhotoElement | null {
const back = spread?.elements[0];
if (!spread || !back || back.kind !== 'photo' || !back.locked || back.photoId == null) return null;
const want = bleedFrame(spread, page);
const f = back.frame;
return near(f.x, want.x) && near(f.y, want.y) && near(f.w, want.w) && near(f.h, want.h) && !back.rotation
? back
: null;
}
/**
* The page with `photoId` as its background: the one it had is replaced, a
* new one goes in at the back. Null when the page is already full.
*/
export function withBackgroundPhoto(
spread: BookSpread,
page: BookPageSetup,
photoId: number,
newId: () => string
): BookSpread | null {
const old = backgroundPhotoOf(spread, page);
const rest = old ? spread.elements.slice(1) : spread.elements;
if (rest.length >= MAX_SPREAD_ELEMENTS) return null;
const photo = bookPhotoElementSchema.parse({
id: old?.id ?? newId(),
kind: 'photo',
frame: bleedFrame(spread, page),
locked: true,
photoId,
fit: 'cover',
});
return { ...spread, elements: [photo as BookElement, ...rest] };
}
/** The page without its background photo. */
export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup): BookSpread {
return backgroundPhotoOf(spread, page) ? { ...spread, elements: spread.elements.slice(1) } : spread;
}
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
/**
* Every page given the background of page `from`: its paint and colour,
* nothing else. "Every page" is every page of its kind: an inside page
* gives its background to the inside of the book, a cover to the covers,
* since a cover is designed on its own and an inside page's paper is rarely
* what the cover wants.
*/
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
const source = doc.spreads[from];
if (!source) return doc;
const paint = spreadPaint(source);
const kind = isCover(source);
return {
...doc,
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
};
}
/** The pictures offered as a background: the page's own first, then its entry's, then the gallery's. */
export function backgroundCandidates(
spreadPhotos: readonly number[],
entryPhotos: readonly number[],
gallery: readonly number[],
limit = 8
): number[] {
return [...new Set([...spreadPhotos, ...entryPhotos, ...gallery])].slice(0, limit);
}
@@ -0,0 +1,200 @@
import {
MAX_PAINT_STOPS,
PATTERN_SCALE,
RADIAL_RADIUS,
paintColours,
parseHex,
sortedStops,
stopAlpha,
toHex,
type BookGradientPaint,
type BookPaint,
type BookPaintStop,
type BookPattern,
} from '@trek/shared';
/**
* The arithmetic of the paint picker, kept out of the components so it can
* be tested on its own: switching between kinds without losing the colour,
* stops added where they are clicked in the colour already there, angles
* read off a pointer and snapped to the ones people mean.
*/
export type PaintKind = 'none' | 'solid' | 'gradient' | 'pattern';
const WHITE = '#ffffff';
const INK = '#111111';
export const kindOf = (paint: BookPaint | null): PaintKind =>
!paint ? 'none' : paint.type === 'linear' || paint.type === 'radial' ? 'gradient' : paint.type;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const r3 = (v: number) => Math.round(v * 1000) / 1000;
/** Two colours mixed, `t` of the way from `a` to `b`. */
export function mixHex(a: string, b: string, t: number): string {
const ca = parseHex(a);
const cb = parseHex(b);
if (!ca || !cb) return a.toLowerCase();
const m = (x: number, y: number) => x + (y - x) * t;
return toHex({ r: m(ca.r, cb.r), g: m(ca.g, cb.g), b: m(ca.b, cb.b) });
}
/** The colour a paint leads with: what a solid keeps when it becomes a gradient, and back. */
export function leadColour(paint: BookPaint | null, fallback = INK): string {
return paintColours(paint)[0] ?? fallback;
}
/** A lighter partner for a colour, for the second stop of a fresh gradient or a pattern's ground. */
export function partnerOf(color: string, t = 0.65): string {
const rgb = parseHex(color);
if (!rgb) return WHITE;
// Light colours get a darker partner, so the gradient never runs white into white.
const light = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 > 200;
return mixHex(color, light ? INK : WHITE, light ? 0.35 : t);
}
/** The angle a pattern looks right at when first picked: stripes on the diagonal, the rest square. */
export const PATTERN_ANGLE: Partial<Record<BookPattern, number>> = { stripes: 45, pinstripe: 0 };
/**
* The paint of another kind, built from the one there is. `memory` holds the
* last paint of each kind the picker showed, so going to gradient and back to
* pattern returns the pattern as it was rather than a fresh default.
*/
export function paintAs(
kind: PaintKind,
from: BookPaint | null,
memory: Partial<Record<PaintKind, BookPaint>> = {}
): BookPaint | null {
if (kind === 'none') return null;
const remembered = memory[kind];
if (remembered) return remembered;
const lead = leadColour(from);
switch (kind) {
case 'solid':
return { type: 'solid', color: lead };
case 'gradient':
return {
type: 'linear',
angle: 180,
stops: [
{ at: 0, color: lead },
{ at: 1, color: partnerOf(lead) },
],
};
case 'pattern':
return { type: 'pattern', pattern: 'stripes', fg: lead, bg: partnerOf(lead, 0.82), scale: 1, angle: 45 };
}
}
/** The colour a gradient shows at a point, alpha included, for a stop added there. */
export function colourAt(stops: readonly BookPaintStop[], at: number): { color: string; alpha: number } {
const list = sortedStops(stops);
const first = list[0];
const last = list[list.length - 1];
if (!first || !last) return { color: INK, alpha: 1 };
if (at <= first.at) return { color: first.color.toLowerCase(), alpha: stopAlpha(first) };
if (at >= last.at) return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
for (let i = 1; i < list.length; i++) {
const a = list[i - 1];
const b = list[i];
if (!a || !b || at > b.at) continue;
const t = b.at === a.at ? 0 : (at - a.at) / (b.at - a.at);
return { color: mixHex(a.color, b.color, t), alpha: r3(stopAlpha(a) + (stopAlpha(b) - stopAlpha(a)) * t) };
}
return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
}
const stopOf = (at: number, color: string, alpha: number): BookPaintStop =>
alpha < 1 ? { at, color, alpha } : { at, color };
/** A stop added at `at` in the colour the gradient already has there. Null when the gradient is full. */
export function addStop(paint: BookGradientPaint, at: number): { paint: BookGradientPaint; index: number } | null {
if (paint.stops.length >= MAX_PAINT_STOPS) return null;
const pos = r3(clamp(at, 0, 1));
const { color, alpha } = colourAt(paint.stops, pos);
const stops = [...paint.stops, stopOf(pos, color, alpha)];
return { paint: { ...paint, stops }, index: stops.length - 1 };
}
/** A gradient without one of its stops. A gradient keeps at least two. */
export function removeStop(paint: BookGradientPaint, index: number): BookGradientPaint {
if (paint.stops.length <= 2 || index < 0 || index >= paint.stops.length) return paint;
return { ...paint, stops: paint.stops.filter((_, i) => i !== index) };
}
/** One stop changed; its position kept between 0 and 1 and to three decimals. */
export function updateStop(paint: BookGradientPaint, index: number, patch: Partial<BookPaintStop>): BookGradientPaint {
const stops = paint.stops.map((s, i) => {
if (i !== index) return s;
const next = { ...s, ...patch, at: r3(clamp(patch.at ?? s.at, 0, 1)) };
if (next.alpha !== undefined && next.alpha >= 1) delete next.alpha;
return next;
});
return { ...paint, stops };
}
/** The gradient run the other way. */
export function reverseStops(paint: BookGradientPaint): BookGradientPaint {
return { ...paint, stops: paint.stops.map((s) => ({ ...s, at: r3(1 - s.at) })) };
}
/** Linear or radial, keeping the stops. */
export function gradientAs(paint: BookGradientPaint, type: 'linear' | 'radial'): BookGradientPaint {
if (paint.type === type) return paint;
return type === 'linear'
? { type: 'linear', angle: 180, stops: paint.stops }
: { type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: paint.stops };
}
/** A whole-degree angle between 0 and 359. */
export function normAngle(deg: number): number {
const a = Math.round(deg) % 360;
return a < 0 ? a + 360 : a;
}
/**
* The angle a pointer at (`dx`, `dy`) from the dial's centre points at, 0 up
* and clockwise like the paint itself. Within a few degrees of a multiple of
* 45 it lands on it, since those are the angles people mean; `fine` (a held
* modifier) turns the snap off.
*/
export function dialAngle(dx: number, dy: number, fine = false): number {
const raw = normAngle((Math.atan2(dx, -dy) * 180) / Math.PI);
if (fine) return raw;
const near = Math.round(raw / 45) * 45;
return Math.abs(raw - near) <= 4 ? normAngle(near) : raw;
}
export const clampScale = (v: number) => r3(clamp(v, PATTERN_SCALE.min, PATTERN_SCALE.max));
export const clampRadius = (v: number) => r3(clamp(v, RADIAL_RADIUS.min, RADIAL_RADIUS.max));
export const clampUnit = (v: number) => r3(clamp(v, 0, 1));
/** A CSS gradient for the picker's own previews (the stop bar, the tab swatches): not for the page. */
export function cssStops(stops: readonly BookPaintStop[]): string {
return sortedStops(stops)
.map((s) => {
const rgb = parseHex(s.color);
const c = rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(s)})` : s.color;
return `${c} ${Math.round(s.at * 1000) / 10}%`;
})
.join(', ');
}
/** A paint as a CSS background, for a preview chip in the picker. */
export function cssPaint(paint: BookPaint | null): string | undefined {
if (!paint) return undefined;
switch (paint.type) {
case 'solid': {
const rgb = parseHex(paint.color);
return rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(paint)})` : paint.color;
}
case 'linear':
return `linear-gradient(${paint.angle}deg, ${cssStops(paint.stops)})`;
case 'radial':
return `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`;
case 'pattern':
return paint.bg ?? undefined;
}
}
@@ -0,0 +1,60 @@
import type { BookShapeElement } from '@trek/shared';
import { SHAPE_PATHS, scalePath } from '../shapes';
import type { PatternBox } from './patterns';
/** The outlines and lines paint is laid along, in millimetres. */
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
export const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
/**
* Where a linear gradient's line runs over a box, the way CSS places it: through
* the centre at `angle` (0 towards the top, clockwise), long enough that the
* first and last stop land on the box's far corners.
*/
export function linearLine(angle: number, box: PatternBox): { x1: number; y1: number; x2: number; y2: number } {
const a = (angle * Math.PI) / 180;
const dx = Math.sin(a);
const dy = -Math.cos(a);
const half = (Math.abs(box.w * dx) + Math.abs(box.h * dy)) / 2;
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
return { x1: r2(cx - dx * half), y1: r2(cy - dy * half), x2: r2(cx + dx * half), y2: r2(cy + dy * half) };
}
/** A rectangle's outline as path data, with its corners rounded to `radius`. */
export function roundedRectPath(w: number, h: number, radius: number): string {
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
if (r <= 0) return `M0 0H${r2(w)}V${r2(h)}H0Z`;
const a = `A${r2(r)} ${r2(r)} 0 0 1`;
return (
`M${r2(r)} 0H${r2(w - r)}${a} ${r2(w)} ${r2(r)}V${r2(h - r)}${a} ${r2(w - r)} ${r2(h)}` +
`H${r2(r)}${a} 0 ${r2(h - r)}V${r2(r)}${a} ${r2(r)} 0Z`
);
}
/** An ellipse filling a box, as path data. */
export function ellipsePath(w: number, h: number): string {
const rx = r2(w / 2);
const ry = r2(h / 2);
return `M0 ${ry}A${rx} ${ry} 0 1 0 ${r2(w)} ${ry}A${rx} ${ry} 0 1 0 0 ${ry}Z`;
}
/** The dash a stroke style draws, proportional to the stroke so its rhythm holds at any width. */
export function strokeDash(style: BookShapeElement['strokeStyle'], sw: number): string | undefined {
if (style === 'dashed') return `${sw * 3} ${sw * 2}`;
if (style === 'dotted') return `${sw * 0.01} ${sw * 2}`;
return undefined;
}
/** The outline of a shape in a box of `w` × `h` millimetres. */
export function shapeOutline(
el: Pick<BookShapeElement, 'shape' | 'radius'>,
w: number,
h: number,
inset: number
): string {
if (el.shape === 'rect') return roundedRectPath(w, h, Math.max(0, el.radius - inset));
if (el.shape === 'ellipse') return ellipsePath(w, h);
return scalePath(SHAPE_PATHS[el.shape], w, h);
}
@@ -0,0 +1,216 @@
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
import { useMemo, type ReactNode, type SVGProps } from 'react';
import { linearLine, r2 } from './paintGeometry';
import { patternGeometry, type PatternBox } from './patterns';
import { usePaintId } from './usePaintId';
/**
* Paint, drawn: the one renderer for a shape's fill and a page's background.
*
* Every paint becomes SVG the print path already knows: a fill with
* `fill-opacity`, a `linearGradient` or `radialGradient` in user space, or a
* pattern drawn out as geometry (patterns.ts) under a `clipPath` in user
* space. Nothing here is lazy, measured or raster, so what the editor shows
* is what headless Chromium prints.
*
* ── Why the ids come from useId ──────────────────────────────────────────
*
* Page mode draws each double spread twice, once per half (bookSheets.ts),
* and a thumbnail draws it again beside the canvas. An id taken from the
* element alone would then exist twice in one document, and `url(#id)`
* resolves to the first, so the second half would borrow the first half's
* gradient, and a pattern clipped to the first copy's path would vanish from
* the second. React's useId is unique per mounted instance and the same on
* every render of it, which is exactly the property a defs id needs; the
* element's own id is kept in front only to make the markup readable.
*/
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
return (
<>
{sortedStops(stops).map((s, i) => (
<stop
// Two stops may share an offset (a hard edge), so the index keys them.
key={i}
offset={r2(s.at)}
stopColor={s.color}
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
/>
))}
</>
);
}
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
interface PaintParts {
defs: ReactNode;
fill: string;
fillOpacity?: number;
/** A pattern's geometry, to draw clipped to the outline over its fill. */
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
}
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
switch (paint.type) {
case 'solid':
return {
defs: null,
fill: paint.color,
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
pattern: null,
};
case 'linear': {
const line = linearLine(paint.angle, box);
return {
defs: (
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
<Stops stops={paint.stops} />
</linearGradient>
),
fill: `url(#${id})`,
pattern: null,
};
}
case 'radial':
return {
defs: (
<radialGradient
id={id}
gradientUnits="userSpaceOnUse"
cx={r2(box.x + paint.cx * box.w)}
cy={r2(box.y + paint.cy * box.h)}
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
>
<Stops stops={paint.stops} />
</radialGradient>
),
fill: `url(#${id})`,
pattern: null,
};
case 'pattern': {
const geo = patternGeometry(paint, box);
return {
defs: null,
fill: paint.bg ?? 'none',
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
};
}
}
}
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
/**
* An outline filled with a paint, inside an `<svg>` whose user units are
* millimetres. `box` is the outline's box in that space, which a gradient and
* a pattern are laid over. The stroke, when the outline has one, is drawn on
* top of a pattern rather than under it, so the edge stays crisp.
*/
export function PaintedPath({
d,
paint,
box,
idPrefix,
fillRule,
stroke,
...rest
}: {
d: string;
paint: BookPaint | null;
box: PatternBox;
/** The element's id, to make the markup readable; uniqueness comes from useId. */
idPrefix: string;
fillRule?: 'evenodd' | 'nonzero';
} & OutlineProps) {
const id = usePaintId(`p-${idPrefix}`);
// The caller holds `box` steady between renders (a memo of the frame), so a re-render
// that moved nothing does not lay the pattern out again.
const parts = useMemo(() => (paint ? paintParts(paint, box, id) : null), [paint, box, id]);
const outline = { d, fillRule, clipRule: fillRule };
if (!parts) return <path {...outline} fill="none" stroke={stroke} {...rest} />;
const pattern = parts.pattern;
return (
<>
{(parts.defs || pattern) && (
<defs>
{parts.defs}
{pattern && (
<clipPath id={pattern.clipId} clipPathUnits="userSpaceOnUse">
<path {...outline} />
</clipPath>
)}
</defs>
)}
<path
{...outline}
fill={parts.fill}
fillOpacity={parts.fillOpacity}
stroke={pattern ? undefined : stroke}
{...(pattern ? {} : rest)}
/>
{pattern && (
<>
<g clipPath={`url(#${pattern.clipId})`}>
<g transform={pattern.transform}>
{pattern.layers.map((layer, i) => (
<path
key={i}
d={layer.d}
fill={pattern.ink}
fillOpacity={layer.opacity < 1 ? layer.opacity : undefined}
/>
))}
</g>
</g>
{stroke && <path {...outline} fill="none" stroke={stroke} {...rest} />}
</>
)}
</>
);
}
/**
* A page's paint, drawn under everything on it and out into the bleed.
*
* Positioned in the spread's own millimetres from minus the bleed, so print,
* whose window shows the bleed, gets paint to the cut, and the editor, which
* clips at the trim, shows the same paint up to it.
*/
export function SpreadPaint({
paint,
width,
height,
bleed,
idPrefix,
}: {
paint: BookPaint;
width: number;
height: number;
bleed: number;
idPrefix: string;
}) {
const w = r2(width + bleed * 2);
const h = r2(height + bleed * 2);
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
return (
<svg
aria-hidden
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
// A page is laid out left to right in every language; its own direction makes the start its left edge.
style={{
position: 'absolute',
direction: 'ltr',
insetInlineStart: `${-bleed}mm`,
top: `${-bleed}mm`,
display: 'block',
pointerEvents: 'none',
}}
>
<PaintedPath d={`M0 0H${w}V${h}H0Z`} paint={paint} box={box} idPrefix={`bg-${idPrefix}`} />
</svg>
);
}
@@ -0,0 +1,63 @@
import type { BookSpread } from '@trek/shared';
import { createContext, useContext } from 'react';
/** The palette rows' state and the pure parts of it (StudioPalette.tsx holds the provider). */
export interface StudioPaletteValue {
/** The colours the book is drawn in, most used first. */
book: string[];
/** The colours of the photos on the page being edited. */
photos: string[];
/** What this person picked lately, newest first. */
recent: string[];
remember: (hex: string) => void;
/** The labels the rows carry, in the reader's language. */
labels: { book: string; photos: string; recent: string; opacity: string };
}
export const StudioPaletteContext = createContext<StudioPaletteValue | null>(null);
/** The palette rows, or null outside a provider (a swatch row then shows its fixed inks only). */
export function useStudioPalette(): StudioPaletteValue | null {
return useContext(StudioPaletteContext);
}
export const RECENT_KEY = 'trek.studio.recentColours';
export const MAX_RECENT = 8;
/** The recent colours as stored. Storage can be missing or refuse; that is an empty list, not an error. */
export function readRecent(): string[] {
try {
const raw = window.localStorage.getItem(RECENT_KEY);
const list: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(list)
? list.filter((c): c is string => typeof c === 'string' && /^#[0-9a-f]{6}$/.test(c)).slice(0, MAX_RECENT)
: [];
} catch {
return [];
}
}
/** A colour put first in the recent list, which keeps each colour once. */
export function withRecent(list: readonly string[], hex: string): string[] {
const c = hex.toLowerCase();
return [c, ...list.filter((x) => x !== c)].slice(0, MAX_RECENT);
}
export function writeRecent(list: readonly string[]) {
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(list));
} catch {
// A private window or a full quota: the list lives for this session only.
return;
}
}
/** The photo ids a page shows, placed or parked, in paint order. */
export function spreadPhotoIds(spread: BookSpread | undefined): number[] {
if (!spread) return [];
const ids = [...spread.elements, ...spread.parked].flatMap((e) =>
e.kind === 'photo' && e.photoId != null ? [e.photoId] : []
);
return [...new Set(ids)].slice(0, 8);
}
@@ -0,0 +1,358 @@
import type { BookPattern } from '@trek/shared';
/**
* The twelve repeats a pattern paint draws, as plain geometry.
*
* Not SVG `<pattern>`: a pattern tile has never been through this print path,
* and a RIP that rasterises tiles at its own resolution prints seams. Each
* repeat is drawn out in full instead, as a handful of `<path>` elements whose
* `d` holds every primitive, and clipped to the shape by the caller. The DOM
* stays a few nodes per element whatever the scale.
*
* Everything is in millimetres in the caller's user space, rounded to
* hundredths as the document stores its own numbers, and every repeat is
* capped at `MAX_PATTERN_PRIMITIVES`: past that the cell grows rather than the
* page, so a tiny scale on a whole spread costs no more than it looks worth.
*/
export const MAX_PATTERN_PRIMITIVES = 2000;
/** The cell of a pattern at scale 1. */
export const PATTERN_CELL_MM = 8;
export interface PatternBox {
x: number;
y: number;
w: number;
h: number;
}
/** One colour layer: every primitive of it in one path, at a share of the ink's opacity. */
interface PatternLayer {
d: string;
opacity: number;
}
interface PatternGeometry {
layers: PatternLayer[];
/** How many primitives the layers hold together. */
count: number;
/** Turns the drawing about the box's centre; absent when it is not turned. */
transform?: string;
/** The cell the repeat was drawn at, after the cap had its say. */
cell: number;
}
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
const f = (v: number) => String(r2(v));
/**
* How many primitives a repeat needs over a region: `area` primitives per
* cell squared, `lin` per cell along the width and the height. Read before
* drawing, so the cap moves the cell once instead of drawing twice, and on
* the high side, so the cap grows the cell rather than leaving a corner of
* the box undrawn.
*/
const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
checker: { area: 2, lin: 0 },
gingham: { area: 0, lin: 1 },
stripes: { area: 0, lin: 1 },
pinstripe: { area: 0, lin: 2 },
dots: { area: 4, lin: 0 },
polka: { area: 2, lin: 0 },
zigzag: { area: 2, lin: 0 },
waves: { area: 2, lin: 0 },
grid: { area: 0, lin: 2 },
scales: { area: 2, lin: 0 },
sunburst: { area: 0, lin: 0 },
confetti: { area: 1.2, lin: 0 },
};
/** A primitive count estimate for a repeat over a region at a cell size. */
export function estimatePrimitives(pattern: BookPattern, cell: number, w: number, h: number): number {
if (pattern === 'sunburst') return sunburstRays(cell);
const d = DENSITY[pattern];
// The repeats that need room to tuck in start a cell before the region and end a cell after it.
const pw = w + cell * 2;
const ph = h + cell * 2;
return (d.area * pw * ph) / (cell * cell) + (d.lin * (pw + ph)) / cell;
}
/** The cell a repeat is drawn at: its own, or larger where the cap would be passed. */
export function fittedCell(
pattern: BookPattern,
cell: number,
w: number,
h: number,
cap = MAX_PATTERN_PRIMITIVES
): number {
let c = cell;
// Two rounds cover the linear term, which grows only with the square root.
for (let i = 0; i < 3; i++) {
const n = estimatePrimitives(pattern, c, w, h);
if (n <= cap * 0.95) break;
c *= Math.sqrt(n / (cap * 0.95)) * 1.02;
}
return c;
}
/** Collects primitives into a path, refusing any past the cap. */
class Pen {
parts: string[] = [];
count = 0;
constructor(private readonly cap: number) {}
get full(): boolean {
return this.count >= this.cap;
}
private push(d: string) {
if (this.full) return;
this.parts.push(d);
this.count++;
}
rect(x: number, y: number, w: number, h: number) {
this.push(`M${f(x)} ${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`);
}
circle(cx: number, cy: number, r: number) {
this.push(`M${f(cx - r)} ${f(cy)}a${f(r)} ${f(r)} 0 1 0 ${f(r * 2)} 0a${f(r)} ${f(r)} 0 1 0 ${f(-r * 2)} 0Z`);
}
polygon(points: readonly (readonly [number, number])[]) {
if (points.length < 3) return;
this.push(`M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`);
}
/** Raw path data counted as `n` primitives, for a band drawn as one outline. */
raw(d: string, n: number) {
if (this.full) return;
this.parts.push(d);
this.count += n;
}
get d(): string {
return this.parts.join('');
}
}
interface Region {
x0: number;
y0: number;
x1: number;
y1: number;
}
/** Values of `start + k * step` from at or below `from` up to `to`, aligned to the region's origin. */
function steps(from: number, to: number, step: number, offset = 0): number[] {
const out: number[] = [];
const first = Math.floor((from - offset) / step) * step + offset;
for (let v = first; v < to && out.length < 4000; v += step) out.push(v);
return out;
}
type Draw = (pen: Pen, region: Region, cell: number, alt: Pen) => void;
const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
checker: (pen, g, s) => {
const half = s / 2;
steps(g.y0, g.y1, half).forEach((y, j) => {
steps(g.x0, g.x1, half).forEach((x, i) => {
if ((i + j) % 2 === 0) pen.rect(x, y, half, half);
});
});
},
// Two layers of half-opaque bands, so where they cross reads darker: the weave.
gingham: (pen, g, s, alt) => {
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, s / 2);
for (const x of steps(g.x0, g.x1, s)) alt.rect(x, g.y0, s / 2, g.y1 - g.y0);
},
stripes: (pen, g, s) => {
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, s / 2, g.y1 - g.y0);
},
pinstripe: (pen, g, s) => {
const w = Math.max(0.2, s * 0.06);
for (const x of steps(g.x0, g.x1, s / 2)) pen.rect(x, g.y0, w, g.y1 - g.y0);
},
dots: (pen, g, s) => {
const p = s / 2;
for (const y of steps(g.y0, g.y1 + p, p))
for (const x of steps(g.x0, g.x1 + p, p)) pen.circle(x + p / 2, y + p / 2, s * 0.07);
},
// A square lattice with a second one in its gaps: the polka of a sundress.
polka: (pen, g, s) => {
const r = s * 0.17;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
for (const x of steps(g.x0 - s, g.x1 + s, s)) {
pen.circle(x, y, r);
pen.circle(x + s / 2, y + s / 2, r);
}
}
},
zigzag: (pen, g, s) => {
const half = s / 2;
const amp = s / 4;
const t = s * 0.14;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
const xs = steps(g.x0 - s, g.x1 + s, half);
const top = xs.map((x, i) => [x, y + (i % 2 ? amp : 0)] as const);
const bottom = [...top].reverse().map(([x, yy]) => [x, yy + t] as const);
pen.raw(`M${[...top, ...bottom].map(([x, yy]) => `${f(x)} ${f(yy)}`).join('L')}Z`, Math.max(1, xs.length - 1));
}
},
waves: (pen, g, s) => {
const half = s / 2;
const amp = s * 0.18;
const t = s * 0.12;
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
const xs = steps(g.x0 - s, g.x1 + s, half);
if (xs.length < 2) continue;
const curve = (dy: number, list: number[], dir: 1 | -1) =>
list
.slice(1)
.map((x, i) => {
const from = list[i] ?? x;
const k = dir === 1 ? i : list.length - 2 - i;
const bend = k % 2 ? amp : -amp;
return `Q${f((from + x) / 2)} ${f(y + dy + bend)} ${f(x)} ${f(y + dy)}`;
})
.join('');
const back = [...xs].reverse();
const first = xs[0] ?? 0;
const last = back[0] ?? 0;
pen.raw(`M${f(first)} ${f(y)}${curve(0, xs, 1)}L${f(last)} ${f(y + t)}${curve(t, back, -1)}Z`, xs.length - 1);
}
},
grid: (pen, g, s) => {
const w = Math.max(0.2, s * 0.05);
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, w, g.y1 - g.y0);
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, w);
},
// Lower half rings in rows half a cell apart, every other row shifted, so each scale tucks under the next.
scales: (pen, g, s) => {
const r = s / 2;
const t = Math.max(0.2, s * 0.08);
steps(g.y0 - s, g.y1 + s, s / 2).forEach((y, row) => {
for (const x of steps(g.x0 - s, g.x1 + s, s, row % 2 ? r : 0)) {
const cx = x + r;
pen.raw(
`M${f(cx - r)} ${f(y)}A${f(r)} ${f(r)} 0 0 0 ${f(cx + r)} ${f(y)}` +
`L${f(cx + r - t)} ${f(y)}A${f(r - t)} ${f(r - t)} 0 0 1 ${f(cx - r + t)} ${f(y)}Z`,
1
);
}
});
},
confetti: (pen, g, s) => {
const rand = mulberry32(Math.round(s * 1000) + 17);
const area = (g.x1 - g.x0) * (g.y1 - g.y0);
const n = Math.round((area / (s * s)) * 1.2);
for (let i = 0; i < n && !pen.full; i++) {
const x = g.x0 + rand() * (g.x1 - g.x0);
const y = g.y0 + rand() * (g.y1 - g.y0);
const turn = rand() * Math.PI;
if (rand() < 0.35) {
pen.circle(x, y, s * (0.04 + rand() * 0.03));
continue;
}
const len = s * (0.12 + rand() * 0.1);
const wid = s * 0.05;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
const corner = (u: number, v: number) => [x + u * cos - v * sin, y + u * sin + v * cos] as const;
pen.polygon([
corner(-len / 2, -wid / 2),
corner(len / 2, -wid / 2),
corner(len / 2, wid / 2),
corner(-len / 2, wid / 2),
]);
}
},
};
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** How many rays a sunburst has at a cell size: more as the cell shrinks, between 8 and 72. */
export function sunburstRays(cell: number): number {
const n = Math.round((24 * PATTERN_CELL_MM) / Math.max(0.1, cell));
return Math.max(8, Math.min(72, n - (n % 2)));
}
function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
// Past the corners whichever way it turns.
const reach = Math.hypot(box.w, box.h);
const n = sunburstRays(cell);
const step = (Math.PI * 2) / n;
const start = (angle * Math.PI) / 180 - Math.PI / 2;
for (let i = 0; i < n; i += 2) {
const a = start + i * step;
pen.polygon([
[cx, cy],
[cx + Math.cos(a) * reach, cy + Math.sin(a) * reach],
[cx + Math.cos(a + step) * reach, cy + Math.sin(a + step) * reach],
]);
}
}
/**
* A repeat over a box. The geometry covers the box however it is turned: the
* region drawn is the square around the box's circumscribed circle, and the
* caller turns it with `transform` and clips it to the shape.
*/
export function patternGeometry(
paint: { pattern: BookPattern; scale: number; angle: number },
box: PatternBox,
cap = MAX_PATTERN_PRIMITIVES
): PatternGeometry {
const empty = { layers: [], count: 0, cell: PATTERN_CELL_MM * paint.scale };
if (!(box.w > 0 && box.h > 0) || !Number.isFinite(paint.scale) || paint.scale <= 0) return empty;
const turned = ((paint.angle % 360) + 360) % 360 !== 0;
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
const radius = Math.hypot(box.w, box.h) / 2;
const region: Region =
turned && paint.pattern !== 'sunburst'
? { x0: cx - radius, y0: cy - radius, x1: cx + radius, y1: cy + radius }
: { x0: box.x, y0: box.y, x1: box.x + box.w, y1: box.y + box.h };
const rw = region.x1 - region.x0;
const rh = region.y1 - region.y0;
const cell = fittedCell(paint.pattern, PATTERN_CELL_MM * paint.scale, rw, rh, cap);
const pen = new Pen(cap);
if (paint.pattern === 'sunburst') {
sunburst(pen, box, cell, paint.angle);
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
}
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
const alt = new Pen(cap);
DRAW[paint.pattern](pen, region, cell, alt);
const count = pen.count + alt.count;
const layers =
paint.pattern === 'gingham'
? [
{ d: pen.d, opacity: 0.5 },
{ d: alt.d, opacity: 0.5 },
]
: [{ d: pen.d, opacity: 1 }];
return {
layers: layers.filter((l) => l.d),
count,
cell,
...(turned ? { transform: `rotate(${f(paint.angle)} ${f(cx)} ${f(cy)})` } : {}),
};
}
@@ -0,0 +1,150 @@
import { clusterPalettes, parseHex, toHex, toHsl, type PaletteSwatch } from '@trek/shared';
/**
* The colours of a photograph, read in the browser while editing.
*
* The server keeps no palette for a photo the client can read, and the page
* being edited is the one whose photos matter, so the swatches are read from
* the pictures themselves: a thumbnail drawn small onto a canvas, its pixels
* sorted into a few clusters by k-means. Small on purpose, 48 pixels across is
* plenty to say a picture is mostly sea and sand, and it costs nothing next to
* the thumbnail the page has already loaded.
*
* The canvas is only read here, never drawn into the book: what prints stays
* the vector page.
*/
/** How many clusters one photo is read into. */
const PHOTO_CLUSTERS = 5;
/** The side of the thumbnail the pixels are read from. */
const SAMPLE = 48;
type Rgb = { r: number; g: number; b: number };
const dist2 = (a: Rgb, b: Rgb) => (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
/** The opaque pixels of an RGBA buffer, every `stride`-th one. */
export function samplePixels(data: ArrayLike<number>, stride = 1): Rgb[] {
const out: Rgb[] = [];
for (let i = 0; i + 3 < data.length; i += 4 * stride) {
if ((data[i + 3] ?? 0) < 128) continue;
out.push({ r: data[i] ?? 0, g: data[i + 1] ?? 0, b: data[i + 2] ?? 0 });
}
return out;
}
/**
* k-means over pixels, seeded by farthest points from the mean so the same
* picture always gives the same answer: no random start, no flicker as the
* inspector re-renders. Clusters come back largest first.
*/
export function kmeansPalette(pixels: readonly Rgb[], k = PHOTO_CLUSTERS, rounds = 8): PaletteSwatch[] {
if (!pixels.length || k < 1) return [];
const mean = pixels.reduce((m, p) => ({ r: m.r + p.r, g: m.g + p.g, b: m.b + p.b }), { r: 0, g: 0, b: 0 });
const first = { r: mean.r / pixels.length, g: mean.g / pixels.length, b: mean.b / pixels.length };
const centres: Rgb[] = [first];
while (centres.length < k) {
let best: Rgb | null = null;
let bestD = 0;
for (const p of pixels) {
const d = Math.min(...centres.map((c) => dist2(c, p)));
if (d > bestD) {
bestD = d;
best = p;
}
}
if (!best) break;
centres.push({ ...best });
}
let sizes: number[] = centres.map(() => 0);
for (let round = 0; round < rounds; round++) {
const sums = centres.map(() => ({ r: 0, g: 0, b: 0, n: 0 }));
for (const p of pixels) {
let at = 0;
let near = Infinity;
centres.forEach((c, i) => {
const d = dist2(c, p);
if (d < near) {
near = d;
at = i;
}
});
const s = sums[at];
if (!s) continue;
s.r += p.r;
s.g += p.g;
s.b += p.b;
s.n++;
}
sums.forEach((s, i) => {
if (s.n) centres[i] = { r: s.r / s.n, g: s.g / s.n, b: s.b / s.n };
});
sizes = sums.map((s) => s.n);
}
return centres
.map((c, i) => ({ hex: toHex(c), share: (sizes[i] ?? 0) / pixels.length }))
.filter((s) => s.share > 0)
.sort((a, b) => b.share - a.share || a.hex.localeCompare(b.hex));
}
/**
* The swatches several photos offer together: the clusters they share, most
* common first, with near-greys and near-duplicates thinned so the row is a
* choice of colours rather than five shades of the same overcast sky.
*/
export function photoSwatches(palettes: readonly PaletteSwatch[][], limit = 6): string[] {
const out: string[] = [];
let greys = 0;
for (const swatch of clusterPalettes(palettes, 10)) {
const rgb = parseHex(swatch.hex);
if (!rgb) continue;
const grey = toHsl(rgb).s < 0.12;
// One grey is a useful neutral; a second is the same colour again.
if (grey && greys >= 1) continue;
if (grey) greys++;
out.push(swatch.hex);
if (out.length >= limit) break;
}
return out;
}
/** Read one picture's palette. Resolves empty when the picture cannot be read, never rejects. */
export function readPhotoPalette(src: string, signal?: AbortSignal): Promise<PaletteSwatch[]> {
return new Promise((resolve) => {
if (signal?.aborted || typeof document === 'undefined') {
resolve([]);
return;
}
const img = new Image();
img.decoding = 'async';
const done = (out: PaletteSwatch[]) => {
signal?.removeEventListener('abort', abort);
resolve(out);
};
const abort = () => {
img.src = '';
done([]);
};
signal?.addEventListener('abort', abort, { once: true });
img.onerror = () => done([]);
img.onload = () => done(paletteOfImage(img));
img.src = src;
});
}
function paletteOfImage(img: HTMLImageElement): PaletteSwatch[] {
const canvas = document.createElement('canvas');
canvas.width = SAMPLE;
canvas.height = SAMPLE;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return [];
try {
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
return kmeansPalette(samplePixels(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data, 2));
} catch {
// A picture from another origin taints the canvas; it simply offers no swatches.
return [];
}
}
@@ -0,0 +1,53 @@
import type { BookDocument, BookPaint } from '@trek/shared';
import { useLayoutEffect, useMemo, useRef } from 'react';
import { useStudioStore } from '../../../store/studioStore';
/**
* How the paint picker writes: a value set while a control is dragged is
* `live`, and the one it is let go on is `commit`.
*
* A drag of a gradient stop or the angle dial passes through dozens of
* values, and each would otherwise be an undo step. So the first live value
* opens a gesture on the store, every live value after it moves the document
* inside that gesture, and the commit closes it: one drag, one undo. A click
* that is a commit with no live value before it is one ordinary step.
*/
export interface PaintChannel {
live: (paint: BookPaint | null) => void;
commit: (paint: BookPaint | null) => void;
}
export function usePaintChannel(write: (paint: BookPaint | null) => (doc: BookDocument) => BookDocument): PaintChannel {
const begin = useStudioStore((s) => s.beginGesture);
const end = useStudioStore((s) => s.endGesture);
const apply = useStudioStore((s) => s.apply);
const commit = useStudioStore((s) => s.commit);
const open = useRef(false);
// The writer is a fresh closure on every render; the channel reads the latest one.
const writer = useRef(write);
useLayoutEffect(() => {
writer.current = write;
});
return useMemo(
() => ({
live: (paint) => {
if (!open.current) {
begin();
open.current = true;
}
apply(writer.current(paint));
},
commit: (paint) => {
if (!open.current) {
commit(writer.current(paint));
return;
}
apply(writer.current(paint));
end();
open.current = false;
},
}),
[begin, end, apply, commit]
);
}
@@ -0,0 +1,11 @@
import { useId } from 'react';
/**
* Defs ids for paint, unique per mounted copy of a page (paintRender.tsx says why).
*/
/** A defs id unique to this mounted instance, safe inside `url(#...)`. */
export function usePaintId(prefix: string): string {
const raw = useId();
return `${prefix}-${raw.replace(/[^\w-]/g, '')}`;
}
@@ -0,0 +1,75 @@
/**
* FE-STUDIO-PRINT-001 to FE-STUDIO-PRINT-004
*
* When the print view of a book may be printed: every picture in, the SVG
* ones too wherever they sit (a map marker, a photo behind a clip path), and
* a ceiling that gives a book of many photographs longer before the Save
* button gives up waiting.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { printReadyCeilingMs, svgImagesToWait, whenReady } from './printReady';
const SVG = 'http://www.w3.org/2000/svg';
const XLINK = 'http://www.w3.org/1999/xlink';
/** A print document as the frame holds it, built from markup. */
function printDoc(markup: string): Document {
const doc = document.implementation.createHTMLDocument('print');
doc.body.innerHTML = markup;
return doc;
}
/** An iframe whose document and window are the given ones, as the print view's frame is once it loaded. */
function frameOf(doc: Document, win: Partial<Window> = {}): HTMLIFrameElement {
const iframe = document.createElement('iframe');
Object.defineProperty(iframe, 'contentDocument', { value: doc, configurable: true });
Object.defineProperty(iframe, 'contentWindow', { value: win, configurable: true });
return iframe;
}
afterEach(() => {
vi.useRealTimers();
});
describe('the pictures to wait for', () => {
it('FE-STUDIO-PRINT-001: finds an SVG image inside a clip path and in the defs, not only a marker photo', () => {
const doc = printDoc(
`<svg xmlns="${SVG}"><defs><clipPath id="c"><image href="/a.jpg"/></clipPath>` +
`<image id="d" href="/b.jpg"/></defs><image href="/c.jpg"/></svg>`
);
expect(svgImagesToWait(doc).map((el) => el.getAttribute('href'))).toEqual(['/a.jpg', '/b.jpg', '/c.jpg']);
});
it('FE-STUDIO-PRINT-002: takes an xlink source, and leaves out an image that names none and would never load', () => {
const doc = printDoc(`<svg xmlns="${SVG}"><image/></svg>`);
const linked = doc.createElementNS(SVG, 'image');
linked.setAttributeNS(XLINK, 'xlink:href', '/x.jpg');
doc.querySelector('svg')?.appendChild(linked);
expect(svgImagesToWait(doc)).toEqual([linked]);
});
});
describe('the ceiling', () => {
it('FE-STUDIO-PRINT-003: waits eight seconds for an ordinary book and fifteen for one of more than 40 photos', () => {
expect(printReadyCeilingMs(0)).toBe(8000);
expect(printReadyCeilingMs(40)).toBe(8000);
expect(printReadyCeilingMs(41)).toBe(15000);
});
it('FE-STUDIO-PRINT-004: holds a large book past eight seconds while an image is still loading', async () => {
vi.useFakeTimers();
const many = Array.from({ length: 41 }, (_, i) => `<image href="/p${i}.jpg"/>`).join('');
const doc = printDoc(`<svg xmlns="${SVG}">${many}</svg>`);
let ready = false;
// A window without its own timers: the wait falls back to the global one, which the fake timers drive.
void whenReady(frameOf(doc)).then(() => {
ready = true;
});
await vi.advanceTimersByTimeAsync(8100);
expect(ready).toBe(false);
await vi.advanceTimersByTimeAsync(7000);
expect(ready).toBe(true);
});
});
@@ -0,0 +1,77 @@
/**
* When the print view of a book is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback, the exact substitution the bundled typefaces exist to prevent.
*/
/** How long the print view waits at most, and the photo count past which a book gets the longer wait. */
const PRINT_READY = {
ceilingMs: 8000,
largeCeilingMs: 15000,
largeBookPhotos: 40,
} as const;
/**
* The longest the Save button stays off for a book of `photos` pictures. A
* book of a few dozen photographs from a slow provider can take longer than
* eight seconds to decode; one that has not finished must not go to the press.
*/
export function printReadyCeilingMs(photos: number): number {
return photos > PRINT_READY.largeBookPhotos ? PRINT_READY.largeCeilingMs : PRINT_READY.ceilingMs;
}
/** What `whenReady` reads off the print document: its `<img>` elements, its SVG and its fonts. */
interface PrintDocument {
images: ArrayLike<Element & { complete?: boolean }>;
querySelectorAll?: (selector: string) => ArrayLike<Element>;
fonts?: { ready: Promise<unknown> };
}
const XLINK = 'http://www.w3.org/1999/xlink';
/**
* The SVG pictures to wait for: every `<image>` that names a source, wherever
* it sits. `doc.images` does not collect them, and they are not only the
* photographs in a map's markers: a photo cut to a mask or set behind clipped
* type is an `<image>` inside a `<clipPath>` or `<defs>`, which the selector
* reaches just the same. One that names no source would never fire an event,
* so it is left out rather than left to hold the button until the ceiling.
*/
export function svgImagesToWait(doc: PrintDocument): Element[] {
const found = Array.from(doc.querySelectorAll?.('image') ?? []);
return found.filter((el) => !!(el.getAttribute('href') || el.getAttributeNS(XLINK, 'href')));
}
/** Resolves when the element has loaded or failed: a picture that will not load must not hold the button hostage. */
function settle(el: Element): Promise<void> {
return new Promise<void>((resolve) => {
el.addEventListener('load', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
});
}
/**
* Wait until the print view's pictures and faces are in, or until the
* ceiling, which rises for a book with many photographs. Without `complete`
* on an SVG image there is no way to tell one that is still coming from one
* whose events fired before this ran, and a Save button that never enables is
* worse than a marker that prints blank.
*/
export async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow;
const doc = iframe.contentDocument as PrintDocument | null;
if (!win || !doc) return;
const imgs = Array.from(doc.images);
const svg = svgImagesToWait(doc);
const images = imgs.map((img) => (img.complete ? Promise.resolve() : settle(img)));
const wait = win.setTimeout?.bind(win) ?? setTimeout;
const ceiling = new Promise<void>((resolve) => {
wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
});
await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
}
+2 -44
View File
@@ -23,6 +23,8 @@
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
import { whenReady } from './printReady'
/*
* The only literal colours in Studio, and deliberately so.
*
@@ -254,47 +256,3 @@ ${singleRule(input)}
return close
}
/**
* When the document is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback — the exact substitution the bundled typefaces exist to prevent.
*/
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise<void>(resolve => {
// A picture that will not load must not hold the button hostage — better a
// book with one gap than a dialog that never finishes.
el.addEventListener('load', () => resolve(), { once: true })
el.addEventListener('error', () => resolve(), { once: true })
})
const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
/*
* The photographs inside the map's markers are SVG `<image>`, which
* `doc.images` does not collect and which has no `complete` to ask, so there
* is nothing to do but wait on the events. Missing them meant a book could go
* to print with its route beads still blank.
*/
const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
/*
* And a ceiling over the lot. Without `complete` to fall back on there is no
* way to tell a picture that is still coming from one whose events already
* fired before this ran, and a Save button that never enables is worse than a
* marker that prints blank.
*/
const wait = win.setTimeout?.bind(win) ?? setTimeout
const ceiling = new Promise<void>(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}
+36 -10
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { normalizeBookDocument } from '@trek/shared'
import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
@@ -54,6 +54,16 @@ import { addListener, removeListener } from '../../api/websocket'
* either: it is remembered and read once that work has reached the server,
* because the save response and the broadcast travel on different channels and
* either can arrive first.
*
* ── Why a book from a newer Studio is never written ───────────────────────
*
* A document says which revision of the format wrote it (book-schema-rev.ts).
* This build reads a newer one as far as it can, and what it cannot read is
* already gone from the copy it holds: saving that copy would delete the rest
* for everyone. So such a book is shown and never written, the same way a
* viewer's is, until a reload brings the Studio that wrote it. The server
* refuses the save as well, which covers a tab that loaded the book before the
* newer Studio saved it.
*/
/** How long the document has to stay still before it is written. */
@@ -69,6 +79,8 @@ export type SaveState =
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
/** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
| { status: 'newer' }
| { status: 'error' }
/** What the editor needs to know about a version it is handed from elsewhere. */
@@ -176,7 +188,12 @@ export function useBookStore(
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
const read = res.book ? readBookDocument(res.book.document) : null
const book = res.book && read ? { ...res.book, document: read.document } : null
if (read?.newer) {
blocked.current = true
setState({ status: 'newer' })
}
setRecord(book)
version.current = book?.version ?? null
bookId.current = book?.id ?? null
@@ -201,7 +218,8 @@ export function useBookStore(
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
document: next.document,
// Stamped on the way out only: `synced` below keeps the editor's own object.
document: stampBookSchemaRev(next.document),
baseVersion: version.current ?? undefined,
})
setRecord(saved)
@@ -217,8 +235,12 @@ export function useBookStore(
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
if (res?.status === 409 && res.data?.current) {
const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
// A newer Studio saved the book since this tab read it: nothing here may be written over it.
blocked.current = true
setState({ status: 'newer' })
} else if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
@@ -305,15 +327,15 @@ export function useBookStore(
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
const document = normalizeBookDocument(current.document)
blocked.current = false
const { document, newer } = readBookDocument(current.document)
blocked.current = newer
setRecord({ ...current, document })
version.current = current.version
bookId.current = current.id
synced.current = document
latest.current = document
pending.current = null
setState({ status: 'idle' })
setState(newer ? { status: 'newer' } : { status: 'idle' })
// A save from elsewhere passed over while the conflict was open may be
// newer than the record it carried.
catchUp.current()
@@ -388,8 +410,12 @@ export function useBookStore(
if (sameBook && !isNewer(res.book.version)) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
// gained since.
const document = normalizeBookDocument(res.book.document)
// gained since. One written by a newer build is shown and held.
const { document, newer } = readBookDocument(res.book.document)
if (newer) {
blocked.current = true
setState({ status: 'newer' })
}
const held = version.current
const taken = res.book.version
// Undoable only when every save it folds in, past the version held,
+1 -1
View File
@@ -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 }}>
@@ -68,7 +68,7 @@ function seedJourney() {
beforeEach(() => {
seedJourney()
buildBook.mockClear()
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [] })
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
vi.spyOn(journeyApi, 'stats').mockResolvedValue(stats() as never)
@@ -295,6 +295,31 @@ describe('pictures and stops', () => {
expect(result.current.canEdit).toBe(false)
})
/*
* A book a newer Studio saved has already lost, in this copy, what this build
* cannot read. It is shown and never changed, for every role, and let go once
* Studio closes so the next book opens as it should.
*/
it('FE-STUDIO-REV-001: holds a book a newer Studio saved read-only until Studio closes', async () => {
const document = { ...shared.blankBook({ title: 'Iceland' }), schemaRev: shared.BOOK_SCHEMA_REV + 1 }
vi.mocked(journeyApi.getBook).mockResolvedValue({
book: { id: 3, journeyId: 9, title: 'Iceland', version: 4, updatedAt: '', updatedBy: 1, document },
} as never)
const { result, unmount } = await mountStudio()
await waitFor(() => expect(result.current.saveState.status).toBe('newer'))
expect(result.current.canEdit).toBe(false)
expect(result.current.canUndo).toBe(false)
expect(useStudioStore.getState().readOnly).toBe(true)
const before = useStudioStore.getState().doc
act(() => { useStudioStore.getState().commit(d => ({ ...d, title: 'edited' })) })
expect(useStudioStore.getState().doc).toBe(before)
expect(journeyApi.saveBook).not.toHaveBeenCalled()
unmount()
expect(useStudioStore.getState().readOnly).toBe(false)
})
})
/**
@@ -355,7 +380,7 @@ describe('saves from elsewhere', () => {
const { ok } = await openAndFlush()
expect(ok).toBe(true)
const calls = vi.mocked(journeyApi.saveBook).mock.calls
expect(calls[calls.length - 1][1].document.title).toBe('edited')
expect((calls[calls.length - 1][1].document as { title?: string }).title).toBe('edited')
})
it('FE-STUDIO-WIRE-002: a save of this user keeps the page by its id and is one undo away', async () => {
@@ -78,6 +78,7 @@ export function useJourneyStudio() {
const past = useStudioStore(s => s.past)
const future = useStudioStore(s => s.future)
const commit = useStudioStore(s => s.commit)
const setReadOnly = useStudioStore(s => s.setReadOnly)
const journeyId = Number(id)
const backTo = `/journey/${id}`
@@ -117,6 +118,21 @@ export function useJourneyStudio() {
// every render — depending on that made the save effect fire per render.
const { queueSave, saveNow, loaded: bookLoaded } = book
/*
* A book a newer Studio saved is shown and never changed here.
*
* This build read it as far as it could, and what it could not read is gone
* from its copy, so an edit made now could only ever be saved by deleting
* the rest. The store refuses edits while this holds and the controls that
* add to the book go with `canEdit`; the save indicator asks for a reload.
* Released when Studio closes, so the next book opens as it should.
*/
const newerBook = book.state.status === 'newer'
useEffect(() => {
setReadOnly(newerBook)
return () => setReadOnly(false)
}, [newerBook, setReadOnly])
/*
* Who else is in here, and where their pointers are.
*
@@ -595,9 +611,10 @@ export function useJourneyStudio() {
/*
* A viewer may open the book and change nothing: not the pages, not the
* journey behind them. The server refuses either anyway; this keeps the
* controls that would only ever answer with a 403 off the screen.
* controls that would only ever answer with a 403 off the screen. A book a
* newer Studio saved is held the same way, for everyone (see `newerBook`).
*/
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer'
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' && !newerBook
const toast = useToast()
/**
@@ -798,8 +815,8 @@ export function useJourneyStudio() {
undo,
redo,
canUndo: past.length > 0,
canRedo: future.length > 0,
canUndo: !newerBook && past.length > 0,
canRedo: !newerBook && future.length > 0,
pxPerMm: PX_PER_MM,
}
@@ -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,
+7 -1
View File
@@ -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([
+2
View File
@@ -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':
+15 -1
View File
@@ -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 = [
+26 -1
View File
@@ -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
+12
View File
@@ -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,59 @@
/**
* FE-STUDIO-READONLY-001 to FE-STUDIO-READONLY-002
*
* A book held as it is (one a newer Studio saved): every edit, undo and redo
* is refused while it holds, and a version from elsewhere is still taken, so
* the page on screen stays current.
*/
import { blankBook, type BookDocument } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useStudioStore } from './studioStore';
const STUDIO = useStudioStore.getState();
function book(title = 'Lisbon'): BookDocument {
let seq = 0;
return blankBook({ title, newId: (prefix) => `${prefix}-${++seq}` });
}
beforeEach(() => {
useStudioStore.setState({ ...STUDIO, doc: null, past: [], future: [], readOnly: false });
});
afterEach(() => {
useStudioStore.getState().setReadOnly(false);
});
describe('a book held as it is', () => {
it('FE-STUDIO-READONLY-001: refuses edits, undo and redo while held, and takes them again once let go', () => {
const store = useStudioStore.getState();
store.load(book());
store.commit((d) => ({ ...d, title: 'one' }));
const edited = useStudioStore.getState().doc;
store.setReadOnly(true);
store.commit((d) => ({ ...d, title: 'two' }));
store.apply((d) => ({ ...d, title: 'three' }));
store.undo();
expect(useStudioStore.getState().doc).toBe(edited);
expect(useStudioStore.getState().canUndo()).toBe(false);
store.setReadOnly(false);
expect(useStudioStore.getState().canUndo()).toBe(true);
store.undo();
expect(useStudioStore.getState().doc?.title).toBe('Lisbon');
expect(useStudioStore.getState().canRedo()).toBe(true);
});
it('FE-STUDIO-READONLY-002: still takes a version saved elsewhere, so the page shown stays current', () => {
const store = useStudioStore.getState();
store.load(book());
store.setReadOnly(true);
const theirs = book('Porto');
store.adoptRemote(theirs, { undoable: false });
expect(useStudioStore.getState().doc).toBe(theirs);
store.load(book('Faro'));
expect(useStudioStore.getState().doc?.title).toBe('Faro');
});
});
+17 -6
View File
@@ -37,6 +37,15 @@ interface StudioState {
* 4.2). Not part of the book or its history; opening a book clears it.
*/
hintSpread: string | null
/**
* Whether the open book is held as it is. True for a book a newer Studio
* saved (useBookStore's `newer`): this build has already dropped what
* it cannot read from its copy, so every edit, undo and redo is refused
* rather than shown as work that can never be saved. Loading and taking a
* remote version still work, so the page stays current while it is shown.
*/
readOnly: boolean
setReadOnly: (readOnly: boolean) => void
load: (doc: BookDocument) => void
/**
@@ -135,7 +144,9 @@ export const useStudioStore = create<StudioState>((set, get) => ({
gestureBase: null,
clipboard: null,
hintSpread: null,
readOnly: false,
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
adoptRemote: (doc, opts) => set(s => {
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
@@ -180,13 +191,13 @@ export const useStudioStore = create<StudioState>((set, get) => ({
}),
commit: fn => set(s => {
if (!s.doc) return {}
if (!s.doc || s.readOnly) return {}
const next = fn(s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
@@ -420,16 +431,16 @@ export const useStudioStore = create<StudioState>((set, get) => ({
undo: () => set(s => {
const prev = s.past[s.past.length - 1]
if (!prev || !s.doc) return {}
if (!prev || !s.doc || s.readOnly) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
}),
redo: () => set(s => {
const next = s.future[0]
if (!next || !s.doc) return {}
if (!next || !s.doc || s.readOnly) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
}),
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
canUndo: () => !get().readOnly && get().past.length > 0,
canRedo: () => !get().readOnly && get().future.length > 0,
}))

Some files were not shown because too many files have changed in this diff Show More