Compare commits

...

66 Commits

Author SHA1 Message Date
Maurice 8dacd0a50c fix(book): the auto layout's drawn templates no longer carry photos of the book they were drawn in
Three photo frames in the templates kept the photo ids of the journey the
templates were read out of (68 and 69). Only empty frames take an entry's
photos, so every book laid out on those templates pointed at those ids: a
broken picture on most installs, and on one where the id exists, someone
else's photo or an unrelated one of the user's own. The frames are empty
now and take the entry's photos like the others. The parity digests of the
five spreads that use them are written anew, as the spec asks for a
deliberate layout change.
2026-10-24 16:59:53 +02:00
Maurice 3c73da4427 fix(assistant): bring the end of a long answer into view once it is written
A new question is held at the top of the view, so its answer reads from its
first line. When the answer carries steps, a strip of places and cards, its
closing words and the changes to apply ended below the view, and the user
saw only the places found. Once the answer is written, the thread now
scrolls to where those closing blocks begin, or to the bottom when they are
shorter than the view, unless the user scrolled it by hand meanwhile.
2026-10-24 16:33:14 +02:00
Maurice cff2e55db7 feat(help): Studio's help as Studio is now, with guides for themes, stickers, travel, text effects, backgrounds, layers and own layouts
The screen texts name the top bar, the rail and the workbench as they are
after the redesign, the travel guide walks through the four tabs, and six
new guides cover what Studio gained since its help was written. Every
Studio picture is taken again, and all texts are translated.
2026-10-24 15:38:36 +02:00
Maurice 429a25faa9 fix(admin): name the addon columns by start and end 2026-10-24 15:10:13 +02:00
Maurice 486a2eeca3 feat(admin): addons tab in the cards of the other tabs, with a search and an on/off filter
The addons sit in a card per type now, the way every other admin tab sets
its cards, instead of three columns nested in one card. A head card shows
how many of each type are on and finds an addon by its name or description,
with a switch between all, the ones on and the ones off.
2026-10-24 15:10:13 +02:00
Maurice 26a6147e8d fix(book): a summary's and a country's look are optional, so a stored book reads and saves as it did
Absent means the classic look. Defaulting it wrote the field into every
element the auto layout and the templates build, which moved their parity
fixtures and grew the page tools past their schema ceiling; the new
descriptions are also shorter for the same reason.
2026-10-24 14:59:00 +02:00
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00
Maurice 3f3e2f0bcf chore(client): hold the refs count and the stylesheet size at what they are 2026-10-24 14:06:40 +02:00
Maurice f956ab07f3 fix(studio): tidy the head row, the layouts tab and the theme panel
Every piece of the head row is one raised tile of one height: the format
picker moved beside the tools, the assistant and help stand alone, the
close button is square like the rest, and the beta mark is gone. The
layouts tab lays the book's own and the built-in layouts out in sections
and in the same grid as the designed pages. In the theme panel the accent
dots sit in the sheet's corner, the three accents share one name, a font
keeps its name ahead of its kind, a doubled rule is gone, and the
inspector's empty hint is shorter.
2026-10-24 13:54:49 +02:00
Maurice 736594696e fix(tours): one summit per mountain in the places along a tour
The index holds a mountain as many pins named by whoever listed it, at its
foot as much as on top, so a hike up Mt. Takao listed it five times under
names like Mt.Takao, 東京都 高尾山 and 高尾山 (Mt. Takao). Rows that are one
place now merge as a group: like names in one spot for every kind, and for a
summit of the index like names anywhere on the mountain, linked through a
name that carries both scripts. The row kept is an OpenStreetMap object over
an index pin, then the plainest name. Two OpenStreetMap summits stay two.
2026-10-24 13:54:48 +02:00
Maurice 1be997f7d8 test(studio): the pattern picker test counts the tiles against BOOK_PATTERNS 2026-10-24 11:36:30 +02:00
Maurice 5a8ff16afa test(studio): the shape swap test finds the ellipse by its translated name 2026-10-24 11:28:13 +02:00
Maurice 4435dc32b8 refactor(studio): gradient stops built by a plain function; the letters' fill in a helper 2026-10-24 11:07:16 +02:00
Maurice f5a33e0c1e refactor(studio): a paint's SVG parts in a module of their own; the pattern letters a component of their own 2026-10-24 11:07:16 +02:00
Maurice a8326b6f5a chore(client): the shape name entry at the end of the dynamic key list, beside the sticker one 2026-10-24 11:07:16 +02:00
Maurice 1e999d968a fix(studio): the new stickers drawn with the path commands the catalogue takes; the text fill test without Array.at 2026-10-24 11:07:16 +02:00
Maurice d0f74c9f02 feat(studio): add_book_pages, set_book_cover and design_book's cover take the decorated templates too 2026-10-24 11:07:16 +02:00
Maurice f14f1e41b9 fix(studio): bold text measured at each family's own bold width, so a bold condensed heading no longer overruns its frame 2026-10-24 11:07:16 +02:00
Maurice 9138e722bb feat(studio): twelve new stickers: Parthenon, Milan Cathedral, canal houses, gondola, Lisbon tram, Celtic harp, tiger, monkey, camel and three doodles 2026-10-24 11:07:16 +02:00
Maurice 9695e269e3 fix(studio): shapes named in every language, the align target kept across selections, up and down in a tab grid 2026-10-24 11:07:16 +02:00
Maurice 82bdf62c7b feat(studio): linen and paper grain, two quiet textures among the patterns 2026-10-24 11:07:15 +02:00
Maurice 8893e3afe0 feat(studio): letters filled with a gradient or a pattern 2026-10-24 11:07:15 +02:00
Maurice cd1e7d8fa8 refactor(pdf): call the markdown row directly 2026-10-24 10:39:36 +02:00
Maurice 059e72df69 refactor(pdf): the markdown rows and styles in a module of their own; the shared stop's markdown test beside its component 2026-10-24 10:39:36 +02:00
Maurice dc519b3ea7 fix(journey): a journey's created_at is an epoch number 2026-10-24 10:39:36 +02:00
Maurice ad4074f107 fix: journey year helper takes what a journey row carries; PDF markdown test without casts 2026-10-24 10:39:36 +02:00
Maurice 0534d7b3a7 fix: test strings, logical sides in the PDF markdown styles, one export per markdown module 2026-10-24 10:39:36 +02:00
Maurice a0554c2346 fix: four reported bugs that dev still had
- Dashboard: a browser that names no time zone (Chrome on Windows for a zone
  it cannot map) crashed the world clocks and the whole dashboard. One helper
  now reads the browser zone and every reader picks its fallback; a stored
  zone the browser does not know is left out instead of throwing (#2694).
- Phone: a leg of a multi-leg train or flight showed only its route, and its
  sheet showed the first leg's seat. The day plan subtitle is one helper for
  desktop and phone now, so a leg names its own train or flight, platform and
  seat, translated instead of the hardcoded "Gl."/"Sitz"; the sheet opened
  from a leg shows that leg's number, seat, platform and times (#2701). The
  PDF export translates platform and seat too.
- Journey list: the year badge named the year a journey was written, not the
  years of its trips; a trip over New Year shows both (#2685).
- A place's description and notes are markdown in the planner but printed as
  raw syntax in the PDF export and on a shared trip. Both render it now through
  the app's sanitized markdown pipeline, raw HTML stays text (#2692).

Closes #2694
Closes #2701
Closes #2685
Closes #2692
2026-10-24 10:39:35 +02:00
Maurice bc5af23896 chore(client): lower the test non-null assertion baseline to what the tree holds 2026-10-24 10:08:00 +02:00
Maurice 92be8a7ca5 fix(studio): lint and test fixes: zoom bar labels without the studio object, tile zooms in a helper, tests without non-null assertions or the window toast 2026-10-24 10:07:59 +02:00
Maurice 44ea1ea0ba fix(places): search_place asks for one place per query, so the assistant stops bundling four names into one search 2026-10-24 10:07:59 +02:00
Maurice 3bee600096 fix(assistant): a step that searched the web and called a tool no longer sends ChatGPT an answer to the search, which it refused 2026-10-24 10:07:59 +02:00
Maurice 5b4030f86b fix(studio): the full page notice names no number, so it needs no plural forms 2026-10-24 10:07:59 +02:00
Maurice e2c00ba18b feat(studio): a full page says so instead of swallowing the click 2026-10-24 10:07:59 +02:00
Maurice 1050029842 fix(studio): a saved layout's background unlinks the page from its theme colour 2026-10-24 10:07:58 +02:00
Maurice 64edf899ac fix(studio): stickers without gaps: the ramen bowl overlaps its broth, the croissant is one closed crescent, the nata's ribs stay in their cup 2026-10-24 10:07:58 +02:00
Maurice 0a9075af73 test(studio): the head band 2026-10-24 10:07:58 +02:00
Maurice c3a5383f5e feat(studio): the head band in the dialogs' shape, tools grouped by what they do, book view beside the zoom 2026-10-24 10:07:58 +02:00
Maurice e96813edcf fix(places): search_place says what locationBias does, so the assistant passes the neighbourhood instead of the city centre 2026-10-24 10:07:58 +02:00
Maurice 5e36e38b72 fix(offline): the vector prefetch takes the vector source and each source's own zooms; driving settings sync only with the add-on on 2026-10-24 10:07:58 +02:00
Maurice 890473ebd5 style(i18n): format the translated locale files 2026-10-24 09:41:34 +02:00
Maurice 2b86d1fda7 fix(i18n): translate the English left in the other 26 locales
About 2900 strings still carried the `// en-fallback` placeholder and some
3100 more were unmarked English copies, most of them from the Studio, the
assistant, OAuth and the date picker. Every one of them is translated now;
where the English word is the locale's own one (German "Status", "Backup"),
it is kept and marked `// same-as-en`. Placeholders, markup and product names
are unchanged.

"Studio", the photo book editor's name, joins Atlas and Vacay on the
untranslated check's list of names. The baseline drops from 2900 marked and
3100 identical strings to 1 and 158.
2026-10-24 09:41:33 +02:00
Maurice 1487ad97f1 test(trips): the removal names who removed 2026-10-24 08:58:56 +02:00
Maurice fc27b254bb style(i18n): format the German trip strings 2026-10-24 08:38:12 +02:00
Maurice 658536c217 fix(trips): a member another member removed cannot rejoin through the invite link
Removing a member deleted their membership row only, and the invite link
checked nothing but its own token, so whoever kept the link walked straight
back in (GHSA-wr4c-964p-39jg).

trip_member_removals now notes who another member took off a trip. The invite
link refuses them on preview and on accept with a 403 that says so, old link
or regenerated, and the join page shows that instead of the join button.
Adding them back by name, or through a trip-bound invite or a plugin acting
for a member manager, lifts the note. Leaving a trip yourself leaves none, so
the link keeps working for whoever left and for everybody else.
2026-10-24 08:29:51 +02:00
Maurice 00018959c6 fix(studio): a full page takes no new element, the opacity slider commits a move only, a theme draft survives saves
Every add in the Studio (a sticker, a shape, an icon, a text, a frame, a line,
a travel element, a photo from the content panel or a drop), a text style and
a grid of frames appended without looking at the page's cap of 90 elements,
which paste and duplicate already kept. The save then failed with a 400 and
every later save of the book failed the same way. Each of them now leaves a
full page as it is and selects nothing, as a paste does.

The opacity slider committed on every key let go on it, so the Tab that moved
the focus there wrote the value it was last moved to, an empty undo step at
best and another colour's opacity at worst. It commits once after it moved,
on the key or the pointer let go or when the focus leaves.

The Theme panel threw away colours, faces and motifs being tried whenever the
book came back from a save by another editor or the assistant, because the
same theme read anew is a new object. The draft now follows the book's theme
only when the theme itself changed.
2026-10-24 04:19:25 +00:00
Maurice 117af4a8a7 fix(studio): the sheet's keys leave a key to the control that took it, and the layers tree works by keyboard
The sheet listened on the window in the capture phase, before any control saw
a key. Delete on a focused gradient stop removed the whole shape, an arrow on
the angle dial or the radial centre also moved the shape, and Delete, the
arrows and Ctrl+V pressed on a button of the assistant's window acted on the
page although the window stops its keys. The sheet now listens on the
document as the key bubbles, after every control had it and still before the
page's Escape on the window, and leaves a key whose default was prevented. A
gradient stop takes Delete and Backspace even when the last two stops cannot
go.

The layers panel was a tree a keyboard could not walk: only its toggles could
be focused. The tree is now one stop in the Tab order, the row last focused or
else the selected one. Up and Down move along the rows, Home and End to the
ends, Right opens a group or steps into it and Left closes it or steps from a
member up to its group (mirrored right to left), Enter or Space selects a row
as a click does with Shift adding, and F2 renames it. Enter and Escape in the
name field hand the focus back to its row, and the row in focus is ringed. A
hidden or locked layer that cannot be clicked on the page is reached this way.
2026-10-24 04:06:37 +00:00
Maurice 84e18e4954 fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a
pasted look is meant to be the style operation the assistant sends. It now
runs the engine's own restyle (bookElementStyled): a colour or face pasted by
hand takes the element off the theme, so the next theme applied no longer
brings the old colour back; a plain fill replaces the paint of the shape it
lands on instead of staying hidden under it; and a line takes another line's
look without its points, which are its course, not its look.

A background picked by hand in the inspector kept the page's link to the
theme, so the next theme applied set the page back to the theme's colour. The
picker and the background operation now paint through one helper that takes
the link away (withSpreadPaintByHand). Giving one page's background to every
page carries that page's link along, or takes the others' away when it was
painted by hand.

A built-in layout, a book's own layout and laying a page out again from its
entry ignored locks in the Studio: a locked shape was dropped and a locked
photo poured into a slot unlocked, while the assistant's layout operation left
both where they were. All four now lay a page out around its locked elements
through one function (layoutAroundLocks), which the engine's layout operation
uses too.

Fill page and fill spread kept the photo in its group, so the background
picture was selected and dragged with its caption and listed under the group
at the front. It leaves its group as the engine's fill does, and a group left
with one member goes.
2026-10-24 04:06:27 +00:00
Maurice 090e754077 fix(studio): a restore brings in no photo from outside the journey's gallery, letter fills counted
restore_book_layout took any layout the caller sent, so a layout naming a
photo of another journey was kept, and laying a page out on it put that photo
into this book while place_photo refused the same id. The layout is now held
to the rule a page restore states: every photo it names, in a frame, on a map
marker or in the letters of a text, is in the gallery or on a page of the book
already, or the call is refused with photo_not_in_gallery.

restore_book_page and the design apply read the photos of a page in one place
now (book-photo-refs.ts), which counts the photo a text is filled with. A page
put back with letters filled with a photo from outside the gallery is refused
as a frame of it would be, and a designed page whose letter fill photo left
the gallery since is skipped as photo_gone; on a new book's cover the letters
keep their colour instead, as its frame is emptied.

A saved layout keeps no photo of the page it was taken from in its letters, as
it keeps none in its frames, so laying another page out on it no longer
carries that photo along. A layout kept before this fills such letters with
the first photo of the page it is laid on, or leaves them in their colour.

The style operation checks a letter fill photo only when the change sets the
fill, so a title whose photo left the gallery can still be resized or
recoloured, as the text operation already allowed.
2026-10-24 04:05:59 +00:00
Maurice 4322159d8e fix(i18n): the Studio rail and its tabs in every language, and the rail in the help
The names on the rail and in the tab rows the rebuild puts first (Pages,
Content, Layouts, Elements, Shapes, Text, Photos, Entries) were still English
placeholders in twenty one languages. They carry each language's own word now,
and so does the quotation mark, which the Text tab shows as a word under its
tile instead of only in a tooltip. The Frames tab's name was already translated
in those languages and loses the placeholder mark it still carried.
The help on Studio and the wiki describe the rail as it is: seven sections,
Theme and Layers set apart, the stickers under Elements, and the inspector's
order.
2026-10-24 03:08:27 +00:00
Maurice bdf5767a5f feat(studio): rebuild both side panels around one head, one row and one order
The rail lists its sections in the order a book is made: Pages, Content,
Layouts, Elements and Travel, then Theme and Layers set apart below a rule.
Stickers are a tab of Elements beside the icons instead of a rail section of
their own, and Elements has six tabs (text, shapes, lines, frames with the
grids, icons, stickers). The quotation mark moved to the text styles, the
outline rectangle to the basic shapes and the plain rule to the lines. The rail
and every tab row are tab lists the arrow keys walk through, and a long name
wraps instead of being cut. The icon and sticker searches say in the field how
many match, which the sticker library's own head used to.

Every panel on both sides opens on the same head, and every labelled control
uses one row that moves the control under its label when the two do not fit,
so no label sits in a fixed column any more. Number fields, chips, sliders,
tab labels, theme names and travel cards wrap the same way, a country beside
a stop wraps under itself, and an upload cell holds its words to two lines
with the whole label in its tooltip. The heads, shelves and groups are real
headings, so a screen reader can walk the panels by them.

The inspector shows every kind in one order: what the element is and says,
its position and size, its look, its effects, then Arrange. Align and the
stacking buttons are one Arrange group with a button that opens the page in
Layers; its rows sit under their labels, each in two sets that move to a
second line whole. Rarely used groups start folded and open on an element
that already uses them (a turned element, a map held at a zoom, a summary set
in another face), and every group mounts afresh for the next selection. A
sticker's duplicate angle and opacity are gone in favour of the shared
position group, which now has the turn buttons for every kind. With several
elements selected it keeps their angle, opacity and turn, and the mirror when
all of them are stickers, and leaves the frame's numbers to one element. A
photo's fill page and fill spread sit under its crop. With nothing selected
the inspector shows the page background, the book's theme with the way to the
Theme panel, and the page numbers.

Seventeen translation keys nothing has used since before the rebuild (the old
inspector title, the empty spread note, the summary and country list names,
the roads buttons and others) are gone from every language. The broad key
template the old panels used had counted them as reached; the format names,
the empty photo grid messages and the map source hints are now named where the
key check can read them.

Also fixed on the way: the travel stops list shared its class with the
gradient stops and was laid out as sixteen pixel handles; the alignment
buttons were named in raw English; the map's print resolution note joined its
two halves with a dash.
2026-10-24 03:08:27 +00:00
Maurice e8b071aecd fix(tours): restoring a recorded track and saving a copy wait for the tour's queued edits
Both go straight to the server. While an edit of the same tour still waited in
the queue, the restore went out first and its answer replaced the cached edit,
and the edit replayed afterwards planned the track over again. A copy copied
the server's older line instead of the one on screen. Both now wait the way the
actual track writes do: offline, behind a queued write of the tour, or for a
tour the server has not got yet they refuse with OnlineOnlyError, and the
planner says a connection is needed and the waiting changes go first, in all
27 languages.
2026-10-24 02:18:08 +00:00
Maurice f128346a54 fix(tours): the other ways of a leg follow the points of the call that asks for them
The search behind propose_hike_leg_way is kept for a while under the spots of
the hike, but it kept the finished points of the first call. A later call over
the same spots in another form, such as a round trip given with its way back as
its last point, or with other names or stage ends, got the first call's points
back, and its create_with no longer came back to the start. The search now keeps
only the pins that hold each way, and each call sets them on its own points.
2026-10-24 02:18:08 +00:00
Maurice 5bf6af7afc fix(tours): new points of a saved hike take each saved point once and keep its shaping mark
A new point within 25 m of a saved one took that point's key, name and place
link, even when another new point already stood on it. A stop put in just
before a stop a stage starts at took the stage's key, so the stage left its
day and the undo could not bring it back. The saved points are now matched one
to one, the nearest pairs first, so a point standing on a saved one keeps it.

The points a hike is changed through carry no shaping mark, so an inserted or
removed stop and a reshape turned every shaping point into a stop. A via point
standing on a saved shaping point now shapes the line still, unless a stage
ends there.
2026-10-24 02:18:08 +00:00
Maurice 1a941a6f1d fix(tours): a recorded track is neither turned round nor closed by update_tour
A GPX import keeps no points of its own, and the tour read makes up a start
and an end from its line. update_tour with reshape turned or closed those two
made up points, so the recorded line was replaced by a router line between its
ends. A reshape of a tour without points of its own is now refused with the
recorded track wording, before anything is planned or saved.
2026-10-24 02:18:07 +00:00
Maurice 508e17bc11 fix(assistant): the phone chat holds the focus as a dialog, and its lists close on Escape
The full screen chat is a modal dialog but left the focus on the trip behind
it. It now takes the focus when shown, keeps Tab inside and hands the focus
back when set aside or closed, through the helper MSheet now shares. The
dropdown panels take the focus onto their first entry, close on Escape and
give the focus back to their button; the history and menu buttons say
whether their panel is open, and the open chat is announced as current.
2026-10-24 02:06:24 +00:00
Maurice 4d00858b8a fix(assistant): the window's menu and trip list keep the keyboard in reach
The home chat's trip list opened before its trips had loaded, so the focus
stayed on its button and no trip could be reached by keyboard; the first trip
now takes the focus once the list is there. The header's menu says whether it
is open, and closed by Escape or a pick it hands the focus back to its
button, so the next Escape closes the window again. The open chat in the
history list is announced as the current one.
2026-10-24 02:06:24 +00:00
Maurice 7e5b23808d fix(assistant): the home chat leaves a journey's own writes to the journey
The home chat kept every write whose arguments named no trip and no journey,
so the journey group brought update_journey_entry, delete_journey_entry and
reorder_journey_entry_photos (they name only the entry) and the Dawarich
journal entry (it names the journey as journalId). The writes of the
journey's controllers now stay out by their controller, and journalId counts
as a journey, as the home chat's own contract and prompt say.
2026-10-24 01:41:36 +00:00
Maurice bfcc205f05 fix(assistant): a change left waiting past its day no longer holds the next one back
The open changes of a thread are now the ones still inside their decision
window. A call older than a day already reads as expired in the thread and
can no longer be decided, but it stayed approval_requested until the nightly
sweep, so "+ Tour", "+ Day" and "Plan this" answered decide_first with
nothing left to decide, and a decision had to name the expired call too.
listOpen takes the window's start, and every caller passes the same cutoff.
2026-10-24 01:41:36 +00:00
Maurice a6a9f3bbdd fix(deps): multer, undici, nodemailer and fast-uri past the advisories published overnight
multer 2.5.0 (GHSA-q7qp-rjg7-q26m), undici 7.30.1 and 8.12.3 (GHSA-4qv8-2p9j-2685,
GHSA-f72v-6w45-q472, GHSA-xjj6-f33q-929c), nodemailer 10.2.1 (GHSA-8c8m-p3m8-cjg8,
GHSA-m943-m63m-w62w, GHSA-85x2-q946-cp5h) and fast-uri 3.1.9 (GHSA-w74x-v6f9-j3xg and
three more). The server's own ranges and the root overrides hold the fixed versions as
floors, the nested undici of isomorphic-dompurify's jsdom included.
2026-10-24 01:40:57 +00:00
Maurice 5651c11803 feat(assistant): the book designer works from a brief, with archetype covers, lively spreads and stickers
- design_book takes a brief (energy, decoration, collages, theme), a cover
  archetype and a cover lockup, and pages.overrides that give single pages of
  the plan another beat or layout. Directives written before parse as they
  did, and a design asked for none of it is composed exactly as before.
- The engine composes the seven cover archetypes from the book's own photos,
  words, theme faces and colours, lockups, shapes and stickers, varied by the
  seed: spine band, poster pop, cut-out, illustrated postcard, grid and
  badge, minimal serif and stamp collage. A title the directives give a
  display tier is held to it, and the notes say when a band, a badge or a
  stamp holds it smaller or a cover leaves a layout out.
- A lively or loud book turns two in five hero and grid spreads into
  collages, script overlays, badge grids, stamp rows or two-tone splits, the
  first of them always, turns and frames the prints of its other pages, sets
  its cover title larger and takes one or two, or three to six, stickers of
  its theme's motifs a spread, never over words, a photo's detail or the
  fold. A calm book asked for collages gets them and nothing else.
- Every turn the engine gives a print or a sticker stays within what the
  brief's energy takes, a calm book's within its theme's own, so a design is
  never judged against its own brief.
- New checks name a flat cover, a page without a lead, colours or faces off
  the theme and, for a design made with a brief, a book livelier or calmer
  than it asked, on the pages the design made and on a page revised in the
  draft. Page edits answer with the art checks too, and the assistant's
  cards name them in every language.
- list_book_assets lists the shapes, faces, lockups, themes and patterns
  beside the stickers, and the Studio's shape groups and photo frames are one
  list the assistant reads too.
- The Studio prompt gains an index of themes, archetypes and faces and JSON
  recipes of the elements to add and style for each cover and three lively
  spreads, and a turn that designs the book may preview sixteen pages, still
  three images a request; a viewer is told of none of it.
2026-10-24 01:14:03 +00:00
Maurice ff749611c6 feat(studio): the Templates panel offers the decorated templates with a style filter
The Templates section of the rail now opens on the decorated templates of
the page's kind, covers two to a row and spreads at full width, each card
the page renderer drawing the page the click will lay: this page's photos
and words in it, in the book theme, a cover in the journey's title and
subtitle as the assistant's layout sets it. A row of style chips narrows
the set to one archetype. A click is one undo step on the page as it is
then, refused for the other kind of page, and opens the photos when frames
are left empty. The book's own layouts and the built-in wireframes follow
below, and the help guide and the wiki describe the panel as it now is.

The panel moved out of StudioSidebar into its own file; the sidebar keeps the
rail, and its two physical sides went with the code, so the rtl baseline
entry goes too. The Travel panel and a template's map build the journey
they draw with one helper.
2026-10-24 00:36:00 +00:00
Maurice 49f7a447d3 feat(studio): decorated templates, 21 covers and 16 spreads laid on any page in the book theme
A decorated template is a whole designed page kept as data: full document
elements in fractions of the page they were drawn on, with roles for the
photos and words a page brings, links to the theme for its colours and
faces, groups for its lockups (a title with its date line, a badge with its
words, a stamp with its postmark) and an archetype with tags. Seven cover
archetypes in three variants each and sixteen double spreads ship in
shared/src/book/templates/data.

Laying a page out on one pours its photos into the photo roles in order and
its words into the title, subtitle, date, story and caption roles, takes
the words it has none for from the book or the journal entry, sets the
template in the book theme, scales it to the page format with its bleeds
on the bleed of that page, and parks what has no room, a date line the date
mark was not made of included. A text set on a colour is made to read there
at the size it was fitted to, on a band of ink or paper when neither reads.
A mark set as text, such as a large quotation mark, is design to every
layout and never taken for the words of a page.

A fitted text keeps the size it was drawn at as its ceiling, so a shorter
title grows back, and every fitted text is now solved with eight percent of
headroom on letter width: set in the browser, the bold condensed faces and
several scripts run that much wider than the letter measure, and a fit at
exactly the measure wrapped its last word out of the frame.

The layout operation of edit_book_page and revise_book_design takes the new
ids as a checked string and keeps the name of a locked group,
list_book_layouts lists them with archetype, tags and roles, and the design
prompt names them. A sibling extract script reads a page drawn in the
Studio back into the format, roles, tints and groups kept.
2026-10-24 00:36:00 +00:00
Maurice ea5447056b feat(studio): the seven cover archetypes a decorated book starts from 2026-10-24 00:35:48 +00:00
Maurice baec23cf73 fix(deps): body-parser past the compressed body advisory
body-parser before 1.20.9 and 2.x before 2.4.0 can be held up by trailing
bytes after a compressed request body (GHSA-27r3-jxmh-p828). Both majors are
held as floors in the root overrides, express 5 under Nest and the MCP SDK on
2.4.0 and express 4 on 1.20.9, with qs on 6.16.1 as 2.4.0 asks.
2026-10-23 22:41:20 +00:00
Maurice a202f2dc95 fix: the four reliability findings Sonar reports on dev
- The warmed fonts' key sorts by code units, so the same faces give the same
  key in every locale.
- The nearest weight of a snapshot face starts from the first face it has.
- The round hike search names its candidates with their place in the list
  explicitly instead of handing map a function of two arguments.
2026-10-23 22:40:27 +00:00
1241 changed files with 47377 additions and 12689 deletions
+165 -5
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ──────────────────────────────────────────────────────────────────── // ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail') const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true }) const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side') const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector') const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last() const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
const thumbs = (page: Page) => page.locator('.st-thumb-row') const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */ /** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]') const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
/**
* The hit target of the first text on the spread on screen. The targets above the page are empty
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
*/
async function markTextTarget(page: Page): Promise<void> {
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
await expect(words).toBeVisible()
await words.evaluate(el => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
hit?.setAttribute('data-help-text', '')
})
}
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
async function studioSection(page: Page, name: string): Promise<void> { async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click() await railButton(page, name).click()
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
}, },
{ {
target: p => p.locator('.st-back'), target: p => p.locator('.st-head-close'),
}, },
], ],
}, },
@@ -1431,7 +1445,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
{ {
prepare: async p => { prepare: async p => {
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click() await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible() await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p) await settle(p)
}, },
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
], ],
}, },
'studio-my-layouts': {
guide: guide('studio-my-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-saved-layouts'),
},
],
},
'studio-theme': {
guide: guide('studio-theme'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Theme'),
act: async p => { await studioSection(p, 'Theme') },
},
{
target: p => sidePanel(p).locator('.st-theme-grid'),
act: async p => {
await sidePanel(p).locator('.st-theme-card').nth(1).click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-theme-apply'),
},
],
},
'studio-stickers': {
guide: guide('studio-stickers'),
start: openStudio,
steps: [
{
prepare: async p => { await studioSection(p, 'Elements') },
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
act: async p => {
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-shape-grid').first(),
},
],
},
'studio-travel': { 'studio-travel': {
guide: guide('studio-travel'), guide: guide('studio-travel'),
start: openStudio, start: openStudio,
steps: [ steps: [
{ {
target: p => railButton(p, 'Travel'), target: p => railButton(p, 'Travel'),
act: async p => { await studioSection(p, 'Travel') }, act: async p => {
await studioSection(p, 'Travel')
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
}, },
{ {
target: sidePanel, target: p => sidePanel(p).locator('.st-travel-grid').first(),
},
{
target: p => sidePanel(p).locator('.st-travel-card').first(),
},
],
},
'studio-text-effects': {
guide: guide('studio-text-effects'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await markTextTarget(p)
},
target: p => textTarget(p),
act: async p => {
await textTarget(p).click()
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const head = inspector(p).getByRole('button', { name: 'Effects' })
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
await head.scrollIntoViewIfNeeded()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
},
],
},
'studio-background': {
guide: guide('studio-background'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
},
{
target: p => inspector(p).locator('.st-paint-tabs').first(),
},
],
},
'studio-layers': {
guide: guide('studio-layers'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layers'),
act: async p => {
await studioSection(p, 'Layers')
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-layer').first(),
}, },
], ],
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+7 -7
View File
@@ -1,20 +1,20 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 401, "@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 850, "@typescript-eslint/no-misused-promises": 849,
"no-console": 88, "no-console": 88,
"complexity": 226, "complexity": 222,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130, "trek/no-swallowed-catch": 130,
"max-params": 33, "max-params": 32,
"trek/store-without-selector": 67, "trek/store-without-selector": 67,
"react-hooks/refs": 556, "react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194, "react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165, "@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116, "react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 125, "@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 132, "max-lines-per-function": 130,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12, "react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7, "no-useless-assignment": 7,
@@ -35,7 +35,7 @@
}, },
"tests": { "tests": {
"@typescript-eslint/no-explicit-any": 490, "@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1932, "@typescript-eslint/no-non-null-assertion": 1929,
"@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33, "@typescript-eslint/no-floating-promises": 33,
+7 -5
View File
@@ -117,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}', template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist", because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
}, },
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{ {
file: 'mobile/screens/trip/sheets/MBookingFields.tsx', file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}', template: 'reservations.${s}',
@@ -151,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' + 'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en', 'sticker the library holds against en',
}, },
{
file: 'components/Studio/shapes.ts',
template: 'journey.studio.shapeName.${shape}',
because:
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
'shape of BOOK_SHAPE_GROUPS',
},
]; ];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+2 -2
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1, "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1, "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1, "src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1, "src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1, "src/components/Studio/travel/BadgeIcon.tsx": 1,
"src/components/Todo/useTodoList.ts": 1, "src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1, "src/components/shared/CountryFlag.tsx": 1,
-2
View File
@@ -56,9 +56,7 @@
"components/Studio/StudioCanvas.tsx": 12, "components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1, "components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1, "components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4, "components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1, "components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
+1
View File
@@ -1,6 +1,7 @@
{ {
"components/Studio/LineHandles.tsx": 1, "components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2, "components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1, "mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4 "pages/tripPlanner/PlanMapStage.tsx": 4
} }
+3 -3
View File
@@ -12,11 +12,11 @@
"src/components/PDF/TripPDF.tsx": 1082, "src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953, "src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/PlaceFormModal.test.tsx": 2836, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157, "src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290, "src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595, "src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636, "src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211, "src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2057 "src/styles/studio.css": 1918
} }
-1
View File
@@ -82,7 +82,6 @@
"src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2, "src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1, "src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Tours/planner/useTourPlanner.ts": 2,
+4 -5
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5, "components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3, "components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25, "components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 8, "components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51, "components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21, "components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3, "components/Layout/NewVersionNotice.tsx": 3,
@@ -72,7 +72,7 @@
"components/Map/poiCategories.ts": 7, "components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2, "components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1, "components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8, "components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53, "components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2, "components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5, "components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -103,11 +103,10 @@
"components/Studio/PhotoView.tsx": 2, "components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1, "components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioShell.tsx": 2, "components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5, "components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5, "components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28, "components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107, "components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -296,7 +295,7 @@
"pages/FilesPage.tsx": 11, "pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18, "pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21, "pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 9, "pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4, "pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53, "pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64, "pages/JourneyPage.tsx": 64,
+2 -1
View File
@@ -44,9 +44,10 @@
"components/Studio/SpreadView.tsx": 1, "components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2, "components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1, "components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6, "components/Studio/TravelElements.tsx": 3,
"components/Studio/peerColour.ts": 1, "components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4, "components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2, "components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1, "components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2, "components/shared/DialogShell.tsx": 2,
@@ -712,4 +712,33 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument(); expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument(); expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
}); });
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
}); });
});
+125 -46
View File
@@ -1,7 +1,7 @@
import type { ComponentType } from 'react' import { useState, type ComponentType } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark' import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon' import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon' import AirTrailIcon from '../shared/AirTrailIcon'
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer, toggleToursWanderer,
mapSwitches, mapSwitches,
} = useAddonManager() } = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) { if (loading) {
return ( return (
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
) )
} }
/* One column per type, side by side, instead of three stacked sections. Stacked, each /* A card per type, the way every other admin tab sets its cards: Trip in the
section opened its own set of columns and the tall tiles (Collab, Journey) set a left column, Global and Integration in the right one, each card with the
section's height while its neighbours ran out early. Side by side the column's dialogs' head band carrying the type, its count and what it means. Above
head carries the type, so the tiles need no badge of their own. */ them one card says how many are on and finds an addon by name. */
const groups = [ const groups = [
{ key: 'trip', addons: tripAddons }, { key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons }, { key: 'global', addons: globalAddons },
@@ -214,7 +216,50 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span> </span>
) )
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return ( return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return (
<>
<SettingsCard <SettingsCard
icon={Puzzle} icon={Puzzle}
title={t('admin.addons.title')} title={t('admin.addons.title')}
@@ -227,55 +272,89 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</Tooltip> </Tooltip>
) : undefined} ) : undefined}
> >
{addons.length === 0 ? ( {addons.length === 0
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" /> ? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : ( : <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
</div>
)}
</SettingsCard> </SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
) )
} }
/** The head band of one type's column: icon tile, the type, its count and what it means. */ type AddonView = 'all' | 'on' | 'off'
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }> /**
label: string * The head card's body: a tile per type with how many of it are on, then a
hint: string * search over the addons' names and descriptions and a switch between all,
addons: Addon[] * the ones on and the ones off.
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string t: (key: string, params?: Record<string, unknown>) => string
}) { }) {
const enabled = addons.filter(a => a.enabled).length
return ( return (
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5"> <>
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Icon size={13} strokeWidth={2} /> {groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span> </span>
<div className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3> <span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="inline-flex">
<StatusPill>{enabled}/{addons.length}</StatusPill>
</span> </span>
</Tooltip> <span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
</div>
</div> </div>
) )
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div>
</div>
</>
)
} }
@@ -11,7 +11,7 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react'; import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -79,6 +79,7 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false); const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const menu = useContextMenu(); const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? []; const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1); const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -95,6 +96,14 @@ export default function AssistantHeader({
run(); run();
}; };
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) => const openMenu = (e: MouseEvent) =>
menu.open( menu.open(
e, e,
@@ -187,10 +196,12 @@ export default function AssistantHeader({
</Tooltip> </Tooltip>
<Tooltip label={t('assistant.window.menu')}> <Tooltip label={t('assistant.window.menu')}>
<button <button
ref={moreRef}
type="button" type="button"
onClick={openMenu} onClick={openMenu}
aria-label={t('assistant.window.menu')} aria-label={t('assistant.window.menu')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON} className={ICON_BUTTON}
> >
<Ellipsis size={14} aria-hidden="true" /> <Ellipsis size={14} aria-hidden="true" />
@@ -218,6 +229,7 @@ export default function AssistantHeader({
icon={null} icon={null}
label={th.title} label={th.title}
active={th.id === thread.threadId} active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))} onClick={() => pick(() => thread.openThread(th.id))}
trailing={ trailing={
<span className="flex-shrink-0 text-caption text-content-faint"> <span className="flex-shrink-0 text-caption text-content-faint">
@@ -234,7 +246,7 @@ export default function AssistantHeader({
)} )}
</PopoverListPanel> </PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} /> <ContextMenu menu={menu.menu} onClose={closeMenu} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />} {memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog <ConfirmDialog
isOpen={confirming} isOpen={confirming}
@@ -1,7 +1,7 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react'; import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared'; import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react'; import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { useMemo } from 'react'; import { Fragment, useMemo } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
import AssistantSources from './AssistantSources'; import AssistantSources from './AssistantSources';
import AssistantToolSteps from './AssistantToolSteps'; import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip'; import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts'; import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
import { useAssistantView } from './assistantViewContext'; import { useAssistantView } from './assistantViewContext';
import AssistantCardBlock from './cards/AssistantCardBlock'; import AssistantCardBlock from './cards/AssistantCardBlock';
import { useAssistantShare } from './useAssistantShare'; import { useAssistantShare } from './useAssistantShare';
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
); );
} }
/** One block of an answer, in the shape its kind takes. */
function AnswerBlock({
block,
message,
writing,
streaming,
}: {
block: MessageBlock;
message: StoredMessage;
writing: boolean;
streaming: boolean;
}) {
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
return <AssistantChangeCard items={block.items} />;
}
function Answer({ message }: { message: StoredMessage }) { function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { tripId, variant } = useAssistantView(); const { tripId, variant } = useAssistantView();
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
const share = useAssistantShare(tripId, message, writing); const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1]; const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text'); const hasText = blocks.some((b) => b.kind === 'text');
const tail = tailStart(blocks);
return ( return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2"> <MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block) => { {blocks.map((block, i) => (
if (block.kind === 'text') { <Fragment key={block.key}>
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />; {i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
} <AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />; </Fragment>
if (block.kind === 'places') { ))}
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} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */} {/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && ( {writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption"> <p role="status" className="m-0 text-caption">
@@ -1,6 +1,6 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react'; import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react'; import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus'; import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop'; import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions'; import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useRevealAnswerTail } from './useRevealAnswerTail';
/** /**
* The chat itself: the messages, and under them the line that says what went * The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the * wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its * messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one. A * answer reads from its first line instead of chasing the newest one, and once
* booking file or a place of the trip can be dropped anywhere on it. * the answer is written its end comes into view. A booking file or a place of
* the trip can be dropped anywhere on it.
*/ */
export default function AssistantThread({ tripTitle }: { tripTitle: string }) { export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId); const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId); useFollowSelectedDay(tripId);
const viewport = useRef<HTMLDivElement>(null);
useRevealAnswerTail(viewport, isRunning);
const phone = variant === 'fullscreen'; const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId); const attach = useAssistantAttach(tripId);
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
</span> </span>
</div> </div>
)} )}
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"> <ThreadPrimitive.Viewport
ref={viewport}
turnAnchor="top"
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
>
<AuiIf condition={(s) => s.thread.isEmpty}> <AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} /> <AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && ( {mentionable && (
@@ -1,4 +1,5 @@
import { ChevronRight, Luggage } from 'lucide-react'; import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState'; import EmptyState from '../shared/EmptyState';
@@ -8,11 +9,17 @@ import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicke
/** /**
* The user's trips to go on with in one trip's own chat, from the home chat's * The user's trips to go on with in one trip's own chat, from the home chat's
* head: that chat, its history and its changes belong to the trip. The * head: that chat, its history and its changes belong to the trip. The
* running and the next trip lead. * running and the next trip lead. The list opens before the trips are there,
* so the focus goes onto the first one once they come.
*/ */
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) { export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const trips = useAssistantTripPicker(); const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) { if (trips === null) {
return ( return (
@@ -32,9 +39,10 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0"> <ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip) => ( {trips.map((trip, i) => (
<li key={trip.id}> <li key={trip.id}>
<button <button
ref={i === 0 ? first : undefined}
type="button" type="button"
onClick={() => onPick(trip)} onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover" className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -1,5 +1,5 @@
/** /**
* FE-AI-WIN-001 to FE-AI-WIN-014 * FE-AI-WIN-001 to FE-AI-WIN-016
* *
* The desktop window around the chat: when it shows at all, the button it * The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat * opens from, the home chat outside a trip with its way into one trip's chat
@@ -238,6 +238,34 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true); expect(useAssistantStore.getState().window.open).toBe(true);
}); });
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => { it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false }); seed({ open: true, minimized: false });
const onFocusDay = vi.fn(); const onFocusDay = vi.fn();
@@ -61,7 +61,7 @@ export interface ChangeItem {
/** The answer cards of one group, side by side. */ /** The answer cards of one group, side by side. */
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] }; export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
type MessageBlock = export type MessageBlock =
| { kind: 'text'; key: string; text: string } | { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] } | { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] } | { kind: 'change'; key: string; items: ChangeItem[] }
@@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
packing: { labelKey: 'assistant.change.label.packing' }, packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' }, breaks: { labelKey: 'assistant.change.label.breaks' },
}; };
/**
* Where an answer's closing words and changes begin: the block after its last
* step, strip or card. That end is what the thread brings into view once the
* answer is written.
*/
export function tailStart(blocks: MessageBlock[]): number {
let i = blocks.length;
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
return i;
}
@@ -1,5 +1,5 @@
/** /**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016 * FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
* *
* The pure side of the book's page card: a page named as the Studio names * The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the * it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and * the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio. * where an applied change opens the Studio.
*/ */
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared'; import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@@ -67,6 +67,9 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => { it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp'); expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull'); expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}'); expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -92,6 +95,15 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi'); expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
}); });
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => { it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book(); const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending'); const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,6 +10,7 @@ import {
type BookSpread, type BookSpread,
findBookGrid, findBookGrid,
findBookTemplate, findBookTemplate,
findDecoratedTemplate,
setBookCover, setBookCover,
} from '@trek/shared'; } from '@trek/shared';
@@ -62,6 +63,10 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' }, edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' }, curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' }, effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
}; };
/** A page as the Studio names it: the cover, the first page, double page 3. */ /** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -87,7 +92,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id); const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null; return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
} }
const template = findBookTemplate(layout.id); const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
return template ? t(template.labelKey) : null; return template ? t(template.labelKey) : null;
} }
@@ -37,7 +37,7 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it. // The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' }, preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookStickers' }, list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
}, },
entities: { entities: {
@@ -0,0 +1,109 @@
/**
* FE-AI-REVEAL-001 to FE-AI-REVEAL-007
*
* Once an answer is written, its closing words and changes come into view,
* unless the user scrolled the thread themselves while it was written; and
* where that end begins in an answer's blocks.
*/
import { useRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render';
import { type MessageBlock, tailStart } from './assistantParts';
import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail';
/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */
function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) {
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 });
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height });
viewport.scrollTop = scrollTop;
viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null;
if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect;
}
function Thread({ running }: { running: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useRevealAnswerTail(ref, running);
return (
<div ref={ref} data-testid="viewport" tabIndex={0}>
<span data-assistant-tail />
</div>
);
}
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
describe('useRevealAnswerTail', () => {
let scrollTo: ReturnType<typeof vi.fn>;
beforeEach(() => {
scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 1;
});
});
afterEach(() => vi.restoreAllMocks());
it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => {
const view = render(<Thread running />);
lay(view.getByTestId('viewport'), { height: 2000 });
view.rerender(<Thread running={false} />);
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 }));
});
it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => {
const view = render(<Thread running />);
const viewport = view.getByTestId('viewport');
lay(viewport);
fireEvent.wheel(viewport, { deltaY: 120 });
view.rerender(<Thread running={false} />);
expect(scrollTo).not.toHaveBeenCalled();
});
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
const view = render(<Thread running={false} />);
lay(view.getByTestId('viewport'));
view.rerender(<Thread running={false} />);
expect(scrollTo).not.toHaveBeenCalled();
});
it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => {
const view = render(<Thread running />);
const viewport = view.getByTestId('viewport');
lay(viewport);
fireEvent.wheel(viewport, { deltaY: 120 });
view.rerender(<Thread running={false} />);
view.rerender(<Thread running />);
view.rerender(<Thread running={false} />);
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});
describe('tailScrollTop', () => {
it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => {
const viewport = document.createElement('div');
viewport.innerHTML = '<span data-assistant-tail></span>';
lay(viewport, { tailAt: 1100 });
expect(tailScrollTop(viewport)).toBe(800);
lay(viewport, { tailAt: 300, scrollTop: 500 });
expect(tailScrollTop(viewport)).toBeNull();
});
it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => {
const viewport = document.createElement('div');
lay(viewport);
expect(tailScrollTop(viewport)).toBeNull();
});
});
describe('tailStart', () => {
it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => {
expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2);
expect(tailStart([block('text'), block('places')])).toBe(2);
expect(tailStart([block('text'), block('change')])).toBe(0);
expect(tailStart([])).toBe(0);
});
});
@@ -0,0 +1,61 @@
import { type RefObject, useEffect, useRef } from 'react';
import { prefersReducedMotion } from '../../utils/reducedMotion';
/** Room left above the tail, so its first line does not sit flush on the edge. */
const TAIL_GAP = 12;
/** What reading the thread by hand looks like while the answer is written. */
const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const;
/**
* Where to scroll so the end of the latest answer is in view: the start of its
* closing text and proposed changes (the `data-assistant-tail` mark), or the
* very bottom when that tail is shorter than the view. Null when it is in view
* already, so the thread never moves up.
*/
export function tailScrollTop(viewport: HTMLElement): number | null {
const marks = viewport.querySelectorAll('[data-assistant-tail]');
const tail = marks[marks.length - 1];
if (!tail) return null;
const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight);
return target > viewport.scrollTop + 1 ? target : null;
}
/**
* A new question moves to the top of the view, so its answer reads from the
* first line. A long answer (steps, a strip of places found, cards) then ends
* below the view, and its closing words and the changes to apply were never
* seen. Once the answer is written, this brings that end into view, unless the
* user scrolled the thread themselves while it was written.
*/
export function useRevealAnswerTail(viewport: RefObject<HTMLElement | null>, isRunning: boolean): void {
const scrolledByHand = useRef(false);
const wasRunning = useRef(isRunning);
useEffect(() => {
const el = viewport.current;
if (!el || !isRunning) return;
scrolledByHand.current = false;
const mark = () => {
scrolledByHand.current = true;
};
for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true });
return () => {
for (const type of OWN_SCROLL) el.removeEventListener(type, mark);
};
}, [viewport, isRunning]);
useEffect(() => {
const ended = wasRunning.current && !isRunning;
wasRunning.current = isRunning;
const el = viewport.current;
if (!ended || !el || scrolledByHand.current) return;
// A frame later, so the answer's last blocks have their height.
const frame = requestAnimationFrame(() => {
const top = tailScrollTop(el);
if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
});
return () => cancelAnimationFrame(frame);
}, [viewport, isRunning]);
}
@@ -1,4 +1,4 @@
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share. // FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull(); expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
expect(updateSetting).not.toHaveBeenCalled(); expect(updateSetting).not.toHaveBeenCalled();
}); });
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
this: Intl.DateTimeFormat
) {
return { ...original.call(this), timeZone: undefined as unknown as string };
});
try {
const { result } = renderHook(() => useDashboardTimezones('en'));
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
} finally {
spy.mockRestore();
}
});
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
const { result } = renderHook(() => useDashboardTimezones('en'));
expect(result.current.zones).toEqual(['Europe/Berlin']);
});
}); });
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo']; const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
* clocks tick every 30 seconds, `adding` toggles the zone picker. * clocks tick every 30 seconds, `adding` toggles the zone picker.
*/ */
export function useDashboardTimezones(locale: string) { export function useDashboardTimezones(locale: string) {
const home = Intl.DateTimeFormat().resolvedOptions().timeZone; const home = browserTimeZone();
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const isLoaded = useSettingsStore((s) => s.isLoaded); const isLoaded = useSettingsStore((s) => s.isLoaded);
const updateSetting = useSettingsStore((s) => s.updateSetting); const updateSetting = useSettingsStore((s) => s.updateSetting);
const stored = useSettingsStore((s) => s.settings.dashboard_timezones); const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured. // Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
const zones = stored ?? [home, ...DEFAULT_ZONES]; // A browser that names no zone (#2694) or a zone this browser does not know
// would throw while rendering a clock, so such entries are left out.
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
const setZones = (next: string[]) => { const setZones = (next: string[]) => {
updateSetting('dashboard_timezones', next).catch(() => {}); updateSetting('dashboard_timezones', next).catch(() => {});
}; };
+4
View File
@@ -6,6 +6,7 @@ import {
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound, Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare, Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare,
ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind, ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind,
PaintBucket, Sticker, Type,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import type { HelpIcon } from '../../help/types' import type { HelpIcon } from '../../help/types'
@@ -13,6 +14,9 @@ import type { HelpIcon } from '../../help/types'
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */ /** The registry names icons; the components draw them. Unknown names fall back to the compass. */
const ICONS: Record<HelpIcon, LucideIcon> = { const ICONS: Record<HelpIcon, LucideIcon> = {
plus: Plus, plus: Plus,
sticker: Sticker,
type: Type,
paintBucket: PaintBucket,
pencil: Pencil, pencil: Pencil,
image: Image, image: Image,
copy: Copy, copy: Copy,
@@ -68,12 +68,12 @@ export default function InAppNotificationBell(): React.ReactElement {
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */} {/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} /> <div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div <div
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 'var(--nav-h)', top: 'var(--nav-h)',
insetInlineEnd: 8, insetInlineEnd: 8,
width: 360, width: 380,
maxWidth: 'calc(100vw - 16px)', maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))', maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999, zIndex: 9999,
@@ -81,14 +81,15 @@ export default function InAppNotificationBell(): React.ReactElement {
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{/* Header */} {/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
<div <div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary" <span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
> <Bell className="w-4 h-4" />
<span className="text-sm font-semibold text-content"> </span>
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
{t('notifications.title')} {t('notifications.title')}
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface"> <span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
{unreadCount} {unreadCount}
</span> </span>
)} )}
@@ -98,34 +99,38 @@ export default function InAppNotificationBell(): React.ReactElement {
<button type="button" <button type="button"
onClick={markAllRead} onClick={markAllRead}
title={t('notifications.markAllRead')} title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover" aria-label={t('notifications.markAllRead')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
> >
<CheckCheck className="w-3.5 h-3.5" /> <CheckCheck className="w-4 h-4" />
</button> </button>
)} )}
{notifications.length > 0 && ( {notifications.length > 0 && (
<button type="button" <button type="button"
onClick={() => setConfirmingDeleteAll(true)} onClick={() => setConfirmingDeleteAll(true)}
title={t('notifications.deleteAll')} title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover" aria-label={t('notifications.deleteAll')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-4 h-4" />
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Notification list */} {/* Notification list */}
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
{isLoading && notifications.length === 0 ? ( {isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" /> <div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
</div> </div>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2"> <div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
<Bell className="w-8 h-8 text-content-faint" /> <span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p> <Bell className="w-5 h-5" />
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p> </span>
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
</div> </div>
) : ( ) : (
notifications.slice(0, 10).map(n => ( notifications.slice(0, 10).map(n => (
@@ -135,13 +140,15 @@ export default function InAppNotificationBell(): React.ReactElement {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
<button type="button" <button type="button"
onClick={handleShowAll} onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover" className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
> >
{t('notifications.showAll')} {t('notifications.showAll')}
</button> </button>
</div> </div>
</div>
</>, </>,
document.body document.body
)} )}
@@ -13,6 +13,18 @@ interface NotificationItemProps {
onClose?: () => void onClose?: () => void
} }
/** The round tool beside a notification, quiet until it is pointed at. */
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
/** An answer or a way on, as a pill under the words. */
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
/**
* One notification, as a card in the list: who sent it, what it says, and what
* can be done with it. Unread ones carry a dot in the accent and a faint
* ground; the tools to mark and delete come forward when the card is pointed
* at or focused, so a list reads as messages rather than as rows of buttons.
*/
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement { export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } = const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
@@ -22,29 +34,25 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
const bodyText = t(notification.text_key, notification.text_params) const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key const hasUnknownBody = bodyText === notification.text_key
const unread = !notification.is_read
const answered = notification.response !== null
return ( return (
<div <div
className={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`} className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
> >
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
<div className="flex gap-3 items-start"> <div className="flex gap-3 items-start">
{/* Sender avatar */} {/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0">
{notification.sender_avatar ? ( {notification.sender_avatar ? (
<img <img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
) : ( ) : (
<div <div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
>
{notification.sender_username {notification.sender_username
? notification.sender_username.charAt(0).toUpperCase() ? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" /> : <User className="w-4 h-4" />}
}
</div> </div>
)} )}
</div> </div>
@@ -52,72 +60,67 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug text-content"> <p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
{hasUnknownTitle ? notification.title_key : titleText} {hasUnknownTitle ? notification.title_key : titleText}
</p> </p>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="relative flex-shrink-0 h-7 flex items-center">
<span className="text-xs me-1 text-content-faint"> <span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{relativeTime(notification.created_at, locale)} {relativeTime(notification.created_at, locale)}
</span> </span>
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? ( {notification.is_read ? (
<button type="button" <button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
onClick={() => markUnread(notification.id)}
title={t('notifications.markUnread')}
aria-label={t('notifications.markUnread')}
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
>
<Mail className="w-3.5 h-3.5" /> <Mail className="w-3.5 h-3.5" />
</button> </button>
) : ( ) : (
<button type="button" <button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
aria-label={t('notifications.markRead')}
className="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
>
<CheckCheck className="w-3.5 h-3.5" /> <CheckCheck className="w-3.5 h-3.5" />
</button> </button>
)} )}
<button type="button" <button
type="button"
onClick={() => deleteNotification(notification.id)} onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')} title={t('notifications.delete')}
aria-label={t('notifications.delete')} aria-label={t('notifications.delete')}
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger" className={`${TOOL} hover:text-danger focus-visible:text-danger`}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div> </div>
</div>
<p className="text-xs mt-0.5 leading-relaxed text-content-muted"> <p className="text-caption mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText} {hasUnknownBody ? notification.text_key : bodyText}
</p> </p>
{/* Boolean actions */} {/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && ( {notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2.5">
<button type="button" <button
type="button"
onClick={() => handleRespond('positive')} onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null} disabled={responding || answered}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${ className={`${PILL} ${
notification.response === 'positive' notification.response === 'positive'
? 'bg-content text-surface' ? 'bg-accent text-accent-text'
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}` : `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`} } ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
> >
<Check className="w-3 h-3" /> <Check className="w-3.5 h-3.5" />
{t(notification.positive_text_key)} {t(notification.positive_text_key)}
</button> </button>
<button type="button" <button
type="button"
onClick={() => handleRespond('negative')} onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null} disabled={responding || answered}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${ className={`${PILL} ${
notification.response === 'negative' notification.response === 'negative'
? 'bg-danger text-white' ? 'bg-danger text-white'
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}` : `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`} } ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
> >
<X className="w-3 h-3" /> <X className="w-3.5 h-3.5" />
{t(notification.negative_text_key)} {t(notification.negative_text_key)}
</button> </button>
</div> </div>
@@ -125,12 +128,13 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{/* Navigate action */} {/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && ( {notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button type="button" <button
type="button"
onClick={handleNavigate} onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover" className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
> >
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)} {t(notification.navigate_text_key)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
</button> </button>
)} )}
</div> </div>
+15 -2
View File
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('€') expect(iframe!.srcdoc).toContain('€')
}) })
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
const place = { ...placeWithDetails, description }
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
await downloadTripPDF(md)
const html = getIframe()?.srcdoc ?? ''
expect(html).toContain('<strong>Colosseum</strong>')
expect(html).toContain('<td>9:00</td>')
expect(html).not.toContain('**Colosseum**')
expect(html).not.toContain('<script>alert(1)</script>')
})
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => { it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
const usdArgs = { const usdArgs = {
...richArgs, ...richArgs,
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
}])) }]))
const html = srcdoc() const html = srcdoc()
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER') expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
}) })
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => { it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
}])) }]))
const html = srcdoc() const html = srcdoc()
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC') expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
expect(html).toContain('EC 2 · MUC → ZRH') expect(html).toContain('EC 2 · MUC → ZRH')
}) })
+11 -8
View File
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs' import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
import { isServiceStopType } from '../Roadtrip/roadtripModel' import { isServiceStopType } from '../Roadtrip/roadtripModel'
import { onlyMyPlan } from './pdfScope' import { onlyMyPlan } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/** /**
* Every day starts a new page by default. On a trip of short days that prints * Every day starts a new page by default. On a trip of short days that prints
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
let hasTransportNotes = false let hasTransportNotes = false
const loc = _locale || undefined const loc = _locale || undefined
const tr = _t || (k => k) const tr = _t || (k => k)
const markdown = createPdfMarkdown()
// The store read is the fallback, not the source: a caller that forgets the // The store read is the fallback, not the source: a caller that forgets the
// prop still prints the reader's own format instead of silently reverting. // prop still prints the reader's own format instead of silently reverting.
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h' const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index] const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
subtitleLines = [[ subtitleLines = [[
l.airline, l.flight_number, l.train_number, l.airline, l.flight_number, l.train_number,
l.platform ? `Gl. ${l.platform}` : '', l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
l.seat ? `Seat ${l.seat}` : '', l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '', (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
source?.confirmation_number, source?.confirmation_number,
].filter(Boolean).join(' · ')].filter(Boolean) ].filter(Boolean).join(' · ')].filter(Boolean)
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
// Multi-leg: one line per leg so every train number + segment route shows, // Multi-leg: one line per leg so every train number + segment route shows,
// with the segment's own booking code where it has one (#1943). // with the segment's own booking code where it has one (#1943).
subtitleLines = legs.map(l => subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '', [l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '', (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number] l.confirmation_number]
.filter(Boolean).join(' · ')) .filter(Boolean).join(' · '))
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
} else { } else {
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name) const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : '' const route = stops.length >= 2 ? stops.join(' → ') : ''
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ') subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
} }
} }
else if (r.type === 'car') { else if (r.type === 'car') {
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
</div> </div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''} ${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''} ${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''} ${place.description ? markdown.row(place.description) : ''}
${chips ? `<div class="chips">${chips}</div>` : ''} ${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''} ${place.notes ? markdown.row(place.notes) : ''}
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''} ${item.data.notes ? markdown.row(item.data.notes) : ''}
</div> </div>
</div>` </div>`
}).join('') }).join('')
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
.info-spacer { width: 13px; flex-shrink: 0; } .info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; } .info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; } .info-text.muted { color: #94a3b8; }
.info-text.italic { font-style: italic; } ${PDF_MARKDOWN_CSS}
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; } .chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; } .chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
@@ -930,6 +932,7 @@ ${tripMapHtml}
${daysHtml} ${daysHtml}
${pluginSectionsHtml} ${pluginSectionsHtml}
</body></html>` </body></html>`
markdown.dispose()
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue) // Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div') const overlay = document.createElement('div')
+30
View File
@@ -0,0 +1,30 @@
import { createMarkdownHtml } from '../shared/markdownHtml';
/**
* A place's description and notes are markdown in the app, so the export
* renders them the same way instead of printing the raw syntax (#2692): one
* info row per text, the HTML from the app's own sanitized pipeline.
*/
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
const markdown = createMarkdownHtml();
return {
row: (text) =>
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
dispose: markdown.dispose,
};
}
/** The markdown's own spacing, lists, tables and links at the export's small size. */
export const PDF_MARKDOWN_CSS = `
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
.md ul, .md ol { padding-inline-start: 14px; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
.md table { border-collapse: collapse; }
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
.md a { color: inherit; text-decoration: underline; }
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
`;
@@ -1,4 +1,4 @@
// FE-PACKPOP-001 to FE-PACKPOP-008 // FE-PACKPOP-001 to FE-PACKPOP-009
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react' import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -56,6 +56,15 @@ describe('PopoverItem', () => {
expect(danger.style.background).toBe('none') expect(danger.style.background).toBe('none')
}) })
it('FE-PACKPOP-009: the entry open now is said to be the current one, not only tinted', () => {
render(<>
<PopoverItem icon={null} label="Temples" active current onClick={() => {}} />
<PopoverItem icon={null} label="Food" onClick={() => {}} />
</>)
expect(screen.getByRole('button', { name: 'Temples' })).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Food' })).not.toHaveAttribute('aria-current')
})
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => { it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' }) expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' }) expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
@@ -7,6 +7,8 @@ interface PopoverItemProps {
label: string label: string
onClick: () => void onClick: () => void
active?: boolean active?: boolean
/** The entry is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
danger?: boolean danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */ /** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean muted?: boolean
@@ -14,11 +16,12 @@ interface PopoverItemProps {
trailing?: ReactNode trailing?: ReactNode
} }
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) { export function PopoverItem({ icon, label, onClick, active = false, current = false, danger = false, muted = false, trailing }: PopoverItemProps) {
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)' const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return ( return (
<button type="button" <button type="button"
onClick={onClick} onClick={onClick}
aria-current={current || undefined}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%', display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer', padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
import { TypeTile } from './bookings/bookingParts' import { TypeTile } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel' import { typeInfo } from './bookings/bookingsModel'
import { transportSubtitle } from './transportDetailModel'
import { dayHeadStays } from './dayHeadStays' import { dayHeadStays } from './dayHeadStays'
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder' import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { import {
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
let meta: any = {} let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} } try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
// Subtitle aus Metadaten zusammensetzen // A leg of a multi-leg flight or train shows its own detail (#2701).
let subtitle = '' const subtitle = transportSubtitle(res, t) ?? ''
if (res.__leg) {
// One leg of a multi-leg flight/train — show this segment's own detail.
const parts = res.type === 'train'
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(', ')
} else if (res.type === 'flight') {
const parts = [meta.airline, meta.flight_number].filter(Boolean)
if (meta.departure_airport || meta.arrival_airport)
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
subtitle = parts.join(', ')
} else if (res.type === 'train') {
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
}
// A transit journey (#1065) renders its itinerary inline — // A transit journey (#1065) renders its itinerary inline —
// line badges in their colors instead of a plain subtitle, // line badges in their colors instead of a plain subtitle,
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string { export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode'); return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
} }
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
export interface TransportLegDetail {
index: number;
from?: string | null;
to?: string | null;
airline?: string | null;
flight_number?: string | null;
train_number?: string | null;
platform?: string | null;
seat?: string | null;
}
/**
* The subtitle of a transport row in the day plan, desktop and phone alike. A
* leg of a multi-leg train or flight names its own train or flight, platform
* and seat (#2701); a whole booking reads them off its metadata. Null for the
* other transport types, which each view labels its own way.
*/
export function transportSubtitle(
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
t: Translate
): string | null {
const meta = parseMeta(res.metadata);
const leg = res.__leg;
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
str(d.train_number),
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
];
if (leg) {
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
return parts.filter(Boolean).join(', ');
}
if (res.type === 'flight') {
const parts = [str(meta.airline), str(meta.flight_number)];
if (meta.departure_airport || meta.arrival_airport) {
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
}
return parts.filter(Boolean).join(', ');
}
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
return null;
}
function str(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
}
function parseMeta(metadata: unknown): Record<string, unknown> {
let meta = metadata;
// An earlier bug could double-encode the metadata; reading it twice heals that.
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
try {
meta = JSON.parse(meta || '{}');
} catch {
meta = {};
}
}
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
}
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client' import { pluginsApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useIsPhone } from '../../mobile/useIsPhone' import { useIsPhone } from '../../mobile/useIsPhone'
import { browserTimeZone } from '../../utils/localDate'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS // The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and // values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
timeFormat: settings.time_format, timeFormat: settings.time_format,
distanceUnit: settings.distance_unit, distanceUnit: settings.distance_unit,
temperatureUnit: settings.temperature_unit, temperatureUnit: settings.temperature_unit,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: browserTimeZone() ?? 'UTC',
blurBookingCodes: Boolean(settings.blur_booking_codes), blurBookingCodes: Boolean(settings.blur_booking_codes),
}, },
tokens: readThemeTokens(), tokens: readThemeTokens(),
+29 -20
View File
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else. * which is exactly the wrong habit for the one time it says something else.
*/ */
export function SaveIndicator({ export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio, state, t, onAcceptTheirs, onKeepMine, onRetry,
}: { }: {
state: SaveState state: SaveState
t: (k: string) => string t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void onKeepMine: (current: BookRecord) => void
onRetry: () => 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') { if (state.status === 'conflict') {
return ( return (
@@ -45,23 +43,8 @@ export function SaveIndicator({
) )
} }
if (state.status === 'newer') { // A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
/* if (state.status === 'newer') return null
* 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') { if (state.status === 'readonly') {
/* /*
@@ -107,6 +90,32 @@ export function SaveIndicator({
return null return null
} }
/**
* A book a newer Studio saved, said over the whole Studio rather than in the
* bar: this tab cannot write the book without losing what the newer one
* added, and only a reload brings the newer Studio here. Until then nothing
* done here would be kept, so nothing can be done: the overlay takes every
* click and offers the one way out there is.
*/
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
t: (k: string) => string
onReload?: () => void
}) {
return (
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
<div className="st-newer-card">
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
<RefreshCw size={15} />
{t('journey.studio.saveReload')}
</button>
</div>
</div>
)
}
/** The default way back to a current Studio: the page again, which loads the newest build. */ /** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() { function reloadStudio() {
window.location.reload() window.location.reload()
+24 -3
View File
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
className?: string; className?: string;
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */ /** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
pxPerMm?: number; pxPerMm?: number;
/**
* Draw the first and the last page beside the blank inside of their cover,
* as the bound book opens on them, so the rail shows why they stand alone.
* The words are the half's name, read out and shown on hover.
*/
coverInside?: string;
} }
/** /**
@@ -37,16 +43,31 @@ export function SpreadThumb({
fold = false, fold = false,
className, className,
pxPerMm = PX_PER_MM, pxPerMm = PX_PER_MM,
coverInside,
}: SpreadThumbProps) { }: SpreadThumbProps) {
const single = spread.role !== 'inner'; const single = spread.role !== 'inner';
const wMm = single ? page.pageWidth : page.pageWidth * 2; // The first page opens on the right of the cover's inside, the last on the left of the back's.
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
const scale = width / (wMm * pxPerMm); const scale = width / (wMm * pxPerMm);
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
return ( return (
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}> <div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
{opens && (
<span
className="st-thumb-inside"
title={coverInside}
aria-label={coverInside}
style={{
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
width: page.pageWidth * pxPerMm * scale,
}}
/>
)}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
insetInlineStart: 0, insetInlineStart: pageLeft,
top: 0, top: 0,
width: `${wMm}mm`, width: `${wMm}mm`,
height: `${page.pageHeight}mm`, height: `${page.pageHeight}mm`,
@@ -56,7 +77,7 @@ export function SpreadThumb({
> >
<SpreadView spread={spread} page={page} folios={folios} /> <SpreadView spread={spread} page={page} folios={folios} />
</div> </div>
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />} {fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div> </div>
); );
} }
@@ -9,26 +9,45 @@ import {
AlignStartHorizontal, AlignStartHorizontal,
AlignStartVertical, AlignStartVertical,
AlignVerticalDistributeCenter, AlignVerticalDistributeCenter,
ArrowDown,
ArrowUp,
ChevronsDown,
ChevronsUp,
Group, Group,
Layers,
Paintbrush, Paintbrush,
Pipette, Pipette,
Ungroup, Ungroup,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-panels.css';
import { Tooltip } from '../shared/Tooltip'; import { Tooltip } from '../shared/Tooltip';
import { Choice, Section } from './StudioControls'; import { Choice, Line, Section } from './StudioControls';
import { useStudioShortcut } from './studioShortcut'; import { useStudioShortcut } from './studioShortcut';
/** /**
* Align, distribute, group and the style clipboard, for whatever is selected. * Arrange: where the selection sits among the other things on the page.
* *
* The align bar lines a multi-selection up within its own box by default, and * Lining it up and spacing it out, what it lines up against, what lies in
* front of what, grouping, and the style clipboard, in one group at the foot
* of the inspector. These were two groups, Align and Arrange, one under the
* other and both about the selection's place among its neighbours, so the
* stacking buttons were a fold away from the alignment they are used with.
*
* Every line puts its row under its label, across the whole width of the
* panel: the six edges and the two spacings are eight buttons, more than fit
* beside a label. Each row is two sets, the edges and the spacings, the stack
* and the grouping, and a set that no longer fits moves to the next line
* whole, so a narrow panel shows two even rows rather than a ragged one.
*
* The align row lines a multi-selection up within its own box by default, and
* a single element (or one whole group) against its page, since there is * a single element (or one whole group) against its page, since there is
* nothing else for it to line up with. Groups move as one box throughout * nothing else for it to line up with. Groups move as one box throughout
* (shared/src/book/book-arrange.ts). Every press is one undo step. * (shared/src/book/book-arrange.ts). The Layers button opens the rail's
* Layers panel, the whole stack of the page beside the selection. Every press
* is one undo step.
*/ */
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
@@ -40,6 +59,13 @@ const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' }, { edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
]; ];
const STACK: { to: 'front' | 'up' | 'down' | 'back'; icon: LucideIcon; labelKey: string }[] = [
{ to: 'front', icon: ChevronsUp, labelKey: 'journey.studio.toFront' },
{ to: 'up', icon: ArrowUp, labelKey: 'journey.studio.forward' },
{ to: 'down', icon: ArrowDown, labelKey: 'journey.studio.backward' },
{ to: 'back', icon: ChevronsDown, labelKey: 'journey.studio.toBack' },
];
/** How many things move as one in a selection: a group counts once. */ /** How many things move as one in a selection: a group counts once. */
function unitCount(spread: BookSpread, ids: string[]): number { function unitCount(spread: BookSpread, ids: string[]): number {
return distributableCount( return distributableCount(
@@ -70,18 +96,29 @@ function IconButton({
); );
} }
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) { export function StudioArrangeSection({
spreadIndex,
t,
onShowLayers,
}: {
spreadIndex: number;
t: (k: string) => string;
/** Opens the Layers panel in the rail; without it the button is left out. */
onShowLayers?: () => void;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]); const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const selection = useStudioStore((s) => s.selection); const selection = useStudioStore((s) => s.selection);
const hasStyle = useStudioStore((s) => s.styleClip !== null); const hasStyle = useStudioStore((s) => s.styleClip !== null);
const align = useStudioStore((s) => s.align); const align = useStudioStore((s) => s.align);
const distribute = useStudioStore((s) => s.distribute); const distribute = useStudioStore((s) => s.distribute);
const raise = useStudioStore((s) => s.raise);
const group = useStudioStore((s) => s.group); const group = useStudioStore((s) => s.group);
const ungroup = useStudioStore((s) => s.ungroup); const ungroup = useStudioStore((s) => s.ungroup);
const copyStyle = useStudioStore((s) => s.copyStyle); const copyStyle = useStudioStore((s) => s.copyStyle);
const pasteStyle = useStudioStore((s) => s.pasteStyle); const pasteStyle = useStudioStore((s) => s.pasteStyle);
const keys = useStudioShortcut(); const keys = useStudioShortcut();
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null); const chosenTo = useStudioStore((s) => s.alignTo);
const setChosenTo = useStudioStore((s) => s.setAlignTo);
if (!spread || !selection.length) return null; if (!spread || !selection.length) return null;
const sel = spread.elements.filter((e) => selection.includes(e.id)); const sel = spread.elements.filter((e) => selection.includes(e.id));
@@ -95,8 +132,11 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
return ( return (
// Open for a multi-selection, where lining up is what comes next; folded for one element. // Open for a multi-selection, where lining up is what comes next; folded for one element.
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}> <Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.arrange')} defaultOpen={units > 1}>
<div className="st-row"> <div className="st-arrange">
<Line label={t('journey.studio.layers.align')} wide>
<div className="st-row st-arrange-row">
<span className="st-arrange-set">
{ALIGN.map((a) => ( {ALIGN.map((a) => (
<IconButton <IconButton
key={a.edge} key={a.edge}
@@ -106,7 +146,8 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
onClick={() => align(spreadIndex, a.edge, to)} onClick={() => align(spreadIndex, a.edge, to)}
/> />
))} ))}
<span className="st-row-sep" aria-hidden /> </span>
<span className="st-arrange-set">
<IconButton <IconButton
label={t('journey.studio.layers.distributeH')} label={t('journey.studio.layers.distributeH')}
icon={AlignHorizontalDistributeCenter} icon={AlignHorizontalDistributeCenter}
@@ -119,9 +160,10 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
disabled={units < 3} disabled={units < 3}
onClick={() => distribute(spreadIndex, 'vertical')} onClick={() => distribute(spreadIndex, 'vertical')}
/> />
</span>
</div> </div>
<div className="st-arrange-to"> </Line>
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span> <Line label={t('journey.studio.layers.alignTo')} wide>
<Choice<BookAlignTo> <Choice<BookAlignTo>
value={to} value={to}
ariaLabel={t('journey.studio.layers.alignTo')} ariaLabel={t('journey.studio.layers.alignTo')}
@@ -132,8 +174,20 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
]} ]}
onPick={setChosenTo} onPick={setChosenTo}
/> />
</div> </Line>
<div className="st-row st-arrange-acts"> <Line label={t('journey.studio.order')} wide>
<div className="st-row st-arrange-row">
<span className="st-arrange-set">
{STACK.map((s) => (
<IconButton
key={s.to}
label={t(s.labelKey)}
icon={s.icon}
onClick={() => raise(spreadIndex, selection, s.to)}
/>
))}
</span>
<span className="st-arrange-set">
{(canGroup || !grouped) && ( {(canGroup || !grouped) && (
<IconButton <IconButton
label={t('journey.studio.layers.groupAction')} label={t('journey.studio.layers.groupAction')}
@@ -151,7 +205,14 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
onClick={() => ungroup(spreadIndex)} onClick={() => ungroup(spreadIndex)}
/> />
)} )}
<span className="st-row-sep" aria-hidden /> {onShowLayers && (
<IconButton label={t('journey.studio.showLayers')} icon={Layers} onClick={onShowLayers} />
)}
</span>
</div>
</Line>
<Line label={t('journey.studio.style')} wide>
<div className="st-row">
<IconButton <IconButton
label={t('journey.studio.layers.copyStyle')} label={t('journey.studio.layers.copyStyle')}
icon={Pipette} icon={Pipette}
@@ -166,6 +227,8 @@ export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number;
onClick={() => pasteStyle(spreadIndex)} onClick={() => pasteStyle(spreadIndex)}
/> />
</div> </div>
</Line>
</div>
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>} {asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
</Section> </Section>
); );
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
return ( return (
<button <button
type="button" type="button"
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`} className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
onClick={assistant.toggle} onClick={assistant.toggle}
aria-pressed={assistant.open} aria-pressed={assistant.open}
aria-label={t('journey.studio.assistant.toggle')}
title={t('journey.studio.assistant.toggleHint')} title={t('journey.studio.assistant.toggleHint')}
> >
<MessageSquare size={14} /> <MessageSquare size={15} />
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
</button> </button>
); );
} }
@@ -1,4 +1,4 @@
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared'; import { bookElementId, spreadPaint, withSpreadPaintByHand, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react'; import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
@@ -37,9 +37,10 @@ export function StudioBackgroundSection({
}) { }) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]); const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit); const commit = useStudioStore((s) => s.commit);
// A colour picked here is the person's, so the page stops following the theme's paper (as the assistant's does).
const channel = usePaintChannel((paint) => (doc) => ({ const channel = usePaintChannel((paint) => (doc) => ({
...doc, ...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)), spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaintByHand(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. // "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
@@ -1,9 +1,11 @@
import type { BookDocument, BookElement } from '@trek/shared'; import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared'; import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render'; import { fireEvent, render } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore'; import { useStudioStore } from '../../store/studioStore';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { StudioCanvas } from './StudioCanvas'; import { StudioCanvas } from './StudioCanvas';
/** /**
@@ -267,6 +269,55 @@ describe('the keyboard', () => {
expect(again.defaultPrevented).toBe(false); expect(again.defaultPrevented).toBe(false);
}); });
it('leaves a key to the control that takes it: a gradient stop, the angle dial', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
draw();
const fade = (n: number) => ({
type: 'linear' as const,
angle: 90,
stops: Array.from({ length: n }, (_, i) => ({ at: i / (n - 1), color: '#112233' })),
});
const onChange = vi.fn();
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
const three = render(
<GradientStopBar paint={fade(3)} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(three.container.querySelectorAll('.st-stop')[1]), { key: 'Delete' });
expect(onChange.mock.calls[0]?.[0].stops).toHaveLength(2);
three.unmount();
// The last two stops cannot go, and the key still does not reach the shape.
const two = render(
<GradientStopBar paint={fade(2)} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(two.container.querySelector('.st-stop')), { key: 'Backspace' });
two.unmount();
expect(elements().map((e) => e.id)).toEqual(['a']);
const dial = render(<AngleDial value={90} onChange={() => {}} label="Angle" />);
fireEvent.keyDown(must(dial.container.querySelector('.st-dial')), { key: 'ArrowRight', shiftKey: true });
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it("leaves the keys pressed in the assistant's window to it", () => {
load([shape('a', 10, 10)]);
store().select(['a']);
store().copy(0, ['a']);
draw();
// As the Studio's assistant window does it: every key pressed in it stops there (StudioAssistantPanel.tsx).
const chat = render(
<aside className="st-assistant" onKeyDown={(e) => e.stopPropagation()}>
<button type="button">Apply</button>
</aside>
);
const button = must(chat.container.querySelector('button'));
fireEvent.keyDown(button, { key: 'Delete' });
fireEvent.keyDown(button, { key: 'ArrowDown' });
fireEvent.keyDown(button, { key: 'v', ctrlKey: true });
expect(elements().map((e) => e.id)).toEqual(['a']);
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it('leaves the keys to a field being typed in', () => { it('leaves the keys to a field being typed in', () => {
load([shape('a', 10, 10)]); load([shape('a', 10, 10)]);
store().select(['a']); store().select(['a']);
@@ -235,7 +235,6 @@ export function StudioCanvas({
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask: null, frameStyle: 'none', mask: null, frameStyle: 'none',
} as BookElement) } as BookElement)
select([id])
}} }}
> >
{/* {/*
@@ -13,9 +13,10 @@ import type { PhotoFilter } from './photoFilter'
* heading over the grid rather than as one control among several, and a long * heading over the grid rather than as one control among several, and a long
* entry name has somewhere to go. * entry name has somewhere to go.
* *
* The popover is the top bar's format menu, hung from the left edge instead of * The popover is the top bar's format menu, hung from the start edge instead
* the right: the side panel is 236px wide and a menu anchored right would open * of the end: the side panel is 236px wide and a menu anchored at the end would
* into the rail. * open into the rail. A long entry name takes two lines in the chip, and the
* menu shows every name whole.
*/ */
export function StudioContentFilter({ export function StudioContentFilter({
source, filter, setFilter, admitted, pageEntryId, t, locale, source, filter, setFilter, admitted, pageEntryId, t, locale,
@@ -79,7 +80,7 @@ export function StudioContentFilter({
aria-expanded={open} aria-expanded={open}
title={t('journey.studio.filterPhotos')} title={t('journey.studio.filterPhotos')}
> >
<span className="st-filter-name">{label}</span> <span className="st-filter-name" title={label}>{label}</span>
<em>{admitted}</em> <em>{admitted}</em>
<ChevronDown size={12} style={{ opacity: .5 }} /> <ChevronDown size={12} style={{ opacity: .5 }} />
</button> </button>
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react' import { useId, useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import ToggleSwitch from '../Settings/ToggleSwitch' import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -31,10 +31,15 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
/** /**
* One group of properties. * One group of properties.
* *
* `defaultOpen={false}` is for the groups you set once and rarely return to — * `defaultOpen={false}` is for the groups you set once and rarely return to:
* the frame's numbers, mostly. The state is per mounted section rather than * the frame's numbers, the effects, the advanced settings of a kind. The
* remembered: coming back to an element and finding its panel folded up * inspector opens such a group anyway when the element already uses it, so a
* differently from last time is worse than opening one group again. * photo with a duotone shows its duotone and a plain one keeps it out of the
* way (inspector/inspectorModel.ts says which groups are in use). The state
* is per mounted section rather than remembered: the inspector mounts its
* groups afresh for every selection, and coming back to an element and finding
* its panel folded up differently from last time is worse than opening one
* group again.
*/ */
export function Section({ label, children, defaultOpen = true, hint, badge }: { export function Section({ label, children, defaultOpen = true, hint, badge }: {
label: string label: string
@@ -51,15 +56,19 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
badge?: string badge?: string
}) { }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
const body = useId()
return ( return (
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}> <div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}> <h3 className="st-section-title">
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
aria-controls={open ? body : undefined}>
<span className="st-section-label">{label}</span> <span className="st-section-label">{label}</span>
{badge && <span className="st-section-badge">{badge}</span>} {badge && <span className="st-section-badge">{badge}</span>}
<ChevronDown size={13} className="st-section-caret" /> <ChevronDown size={13} className="st-section-caret" aria-hidden />
</button> </button>
</h3>
{open && ( {open && (
<div className="st-section-body"> <div className="st-section-body" id={body}>
{hint && <p className="st-hint st-section-hint">{hint}</p>} {hint && <p className="st-hint st-section-hint">{hint}</p>}
{children} {children}
</div> </div>
@@ -68,7 +77,17 @@ export function Section({ label, children, defaultOpen = true, hint, badge }: {
) )
} }
/** A labelled line. The label sits left, the control fills what is left. */ /**
* A labelled line: the label first, the control after it.
*
* The two share the line while they fit and the control moves under the label
* when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság")
* pushes its control down a line instead of being cut or squeezing the control
* to nothing. There is no column of a fixed width for the label to fit into;
* short labels sit at a common minimum, so the controls of a group still start
* at one edge. `wide` puts the control under the label always, for a control
* that needs the whole width, like a row of swatches.
*/
export function Line({ label, children, wide = false }: { export function Line({ label, children, wide = false }: {
label: string label: string
children: ReactNode children: ReactNode
@@ -7,9 +7,10 @@ import {
bookShapeTakesEdge, bookShapeTakesEdge,
} from '@trek/shared'; } from '@trek/shared';
import { NumField, Section } from './StudioControls'; import { edgeInUse } from './inspector/inspectorModel';
import { DEFAULT_EDGE_SIZE } from './shapeOutline'; import { DEFAULT_EDGE_SIZE } from './shapeOutline';
import { edgedPath } from './shapes-edges'; import { edgedPath } from './shapes-edges';
import { NumField, Section } from './StudioControls';
/** /**
* The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn * The edge of a shape or of a photo's cut: a row of tiles, each the edge drawn
@@ -50,12 +51,8 @@ export function EdgeSection({
// A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one. // A heart or a star keeps its drawn line, so there is nothing to offer unless an edge is already set on one.
if (!takes && current === 'straight') return null; if (!takes && current === 'straight') return null;
return ( return (
<Section label={t('journey.studio.edgeStyle')} defaultOpen={current !== 'straight'}> <Section label={t('journey.studio.edgeStyle')} defaultOpen={edgeInUse(edge)}>
{!takes && ( {!takes && <p className="st-hint st-hint-before">{t('journey.studio.edgeUnavailable')}</p>}
<p className="st-hint" style={{ paddingBottom: 8 }}>
{t('journey.studio.edgeUnavailable')}
</p>
)}
<div className="st-mini-shapes"> <div className="st-mini-shapes">
{BOOK_EDGES.map((e) => ( {BOOK_EDGES.map((e) => (
<button <button
@@ -72,7 +69,7 @@ export function EdgeSection({
))} ))}
</div> </div>
{takes && current !== 'straight' && ( {takes && current !== 'straight' && (
<div style={{ marginTop: 10 }}> <div className="st-section-gap">
<NumField <NumField
label={t('journey.studio.edgeSize')} label={t('journey.studio.edgeSize')}
value={edgeSize ?? DEFAULT_EDGE_SIZE} value={edgeSize ?? DEFAULT_EDGE_SIZE}

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