Compare commits

...

14 Commits

Author SHA1 Message Date
Maurice c525b2f84c fix(studio): the riso cover's year band deepens a mid-tone ink so the year reads
In a theme whose second ink is a mid tone (riviera's olive), neither white nor
near black reached 4.5:1 on the year's band. The band is taken towards the dark
until the year reads, as the other covers do for small words.
2026-10-24 20:13:42 +02:00
Maurice b3d30a834e test(studio): pin which texts count as a template's figures 2026-10-24 20:13:42 +02:00
Maurice 589d3354eb fix(studio): a template's figures stay its design, never the next template's date line
Figures a template draws as design, a film's timecode or a frame number, came
back as the page's words when another template was laid on the page, and took
its date line. Texts with no letter that are not a date and are bound to
nothing are design now, and the new templates spell their plate numbers and
REC marks so they read as such.
2026-10-24 20:13:41 +02:00
Maurice 8ce3b24a01 feat(studio): 71 more designed pages and five new styles, each with a theme of its own
Every style now has four covers and five double spreads, 108 in all, and the
spreads of each style take different photo counts, from a words-only page to
eight photos, so a book can be laid on them without an empty frame. The five
new styles come with their own themes: Riso (overprinted spot inks), Magazine
(a travel magazine), Retro travel (vintage posters), Midnight (a dark film
look) and Field notes (a traveller's notebook). Each new style has a cover
composer for the design engine and a recipe for the assistant, and its chip,
theme and template names are translated into every language.
2026-10-24 20:13:41 +02:00
Maurice 06a1dcd730 fix(journey): the share link's copy button and its switches show their words again
The filled buttons set their label in text-accent-on, the accent used as
text on a plain surface, which on the accent's own ground is the same
colour: a black button with black words. They take text-accent-text, the
colour made for words on the accent, and a test pins it.
2026-10-24 18:28:47 +02:00
Maurice 4b633d8cd2 feat(admin): release notes show a #### heading as the feature it names
The release notes in Admin read ## as the version and ### as a section,
and printed a #### line as plain text. A release that heads each feature
inside a section with #### (Tours, TREK Studio under Added) now shows it
as a feature heading between the section and its text, on desktop and on
the phone.
2026-10-24 18:28:44 +02:00
Maurice 728a275fef chore(assistant): drop Connect with Claude from the integration settings
The card that opened claude.ai's custom connector dialog for this instance
goes, with its phone twin, the line in the assistant setup that jumped to it,
its endpoint and its contract. Anyone who wants Claude uses TREK's MCP
server under Integrations, or an Anthropic key in the assistant. It came with
the assistant and never shipped in a release.
2026-10-24 18:28:41 +02:00
Maurice f6befaa6b2 fix(vacay): the opened year's broadcast skips the socket the change came from, like every other
The app only sets days in the years it shows, so the tab that made the change
never needs the new year; the assistant, MCP and plugins send no socket and
reach everyone.
2026-10-24 18:28:38 +02:00
Maurice fd6d3fddd5 fix(vacay): a year a day opens is announced after the day is committed
Opening the year sent vacay:settings from inside the day's transaction, so
a client could reload the years before they were there. The rows go in
with the day, and the broadcast follows the commit. The helpers file is
formatted the way the server's Prettier wants it.
2026-10-24 18:07:02 +02:00
Maurice 280e55bbc9 test(vacay): a day in a year the plan has not opened also announces the new year
The broadcast parity cases toggle a day in 2027, which the seeded plan does
not hold, so both MCP and REST now send vacay:settings for the opened year
before vacay:update.
2026-10-24 17:31:47 +02:00
Maurice e84060cdf7 refactor(vacay): the pure constants live in the helpers, so the service keeps its size budget
The holiday feeds' limits and patterns and the colour palette move to
vacay.helpers.ts, which holds the vacay domain's pure half already. That
keeps the service under its line budget after the missing-year fix.
2026-10-24 17:31:47 +02:00
Maurice fbc85b05a1 fix(vacay): a day set for a year the plan has not opened adds that year
The calendar offers only the plan's years, but a vacation day or company
holiday could be written for any date. A day set months ahead, as the
assistant did for 6 January 2027, was stored and counted yet never shown,
because 2027 was not a year of the plan. Writing a day now adds its year,
with every member's allowance, the way opening it by hand does; the year
is the writer's leave period that holds the date. This holds for the app,
MCP, plugins and the assistant alike, since they all write through here.
2026-10-24 17:31:47 +02:00
Maurice 01333c6ff3 chore(client): hold the complexity count at the notifications redesign's lower one 2026-10-24 17:31:46 +02:00
Maurice aecef03090 feat(notifications): a tile per kind, the list cut into days, and clear actions
Every notification gets a tile in the colour of what it is about (a trip, a
message, a booking, packing, a to-do, Vacay, a collection, shared photos, a
tour, an update or an alert) with the sender's face on its corner, the page
and the bell's panel group the list under Today, Yesterday, the weekday or
the date, and answering or following a notification is a proper button.
The server's UTC timestamps are read as UTC for the grouping and the time.
2026-10-24 17:31:46 +02:00
158 changed files with 24179 additions and 967 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 222,
"complexity": 221,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
-2
View File
@@ -72,7 +72,6 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -295,7 +294,6 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
-5
View File
@@ -5,7 +5,6 @@ import {
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
@@ -233,7 +232,3 @@ export const aiAdminApi = {
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
export const claudeConnectorApi = {
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
};
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
</ul>
);
}
if (block.kind === 'h5') {
return (
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
{block.text}
</h5>
);
}
if (block.kind === 'h4') {
return (
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
riso: { labelKey: 'studio.theme.name.riso' },
magazine: { labelKey: 'studio.theme.name.magazine' },
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
midnight: { labelKey: 'studio.theme.name.midnight' },
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -1,4 +1,4 @@
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
expect(plan).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
})
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
for (const button of filled) {
expect(button.className).toContain('text-accent-text')
expect(button.className).not.toContain('text-accent-on')
}
})
})
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
<button type="button"
onClick={copyLink}
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
link[key]
? 'border-accent bg-accent text-accent-on'
? 'border-accent bg-accent text-accent-text'
: 'border-edge text-content-muted hover:border-content-faint'
}`}
>
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../Notifications/NotificationDayList'
import ConfirmDialog from '../shared/ConfirmDialog'
export default function InAppNotificationBell(): React.ReactElement {
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
position: 'fixed',
top: 'var(--nav-h)',
insetInlineEnd: 8,
width: 380,
width: 400,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
{isLoading && notifications.length === 0 ? (
<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" />
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
)}
</div>
@@ -1,29 +1,53 @@
import React from 'react'
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
}
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
interface NotificationItemProps {
notification: InAppNotification
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'
/** The quiet tool beside a notification, forward only when 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-secondary focus-visible:bg-surface-secondary 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'
/** An answer or a way on, as a button under the words. */
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
function timeOf(value: string, locale: string, justNow: string): string {
const date = createdAt(value)
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
}
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
function KindTile({ notification }: { notification: InAppNotification }) {
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
const initial = notification.sender_username?.charAt(0).toUpperCase()
return (
<span className="relative block self-start flex-shrink-0">
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
<Icon className="w-[18px] h-[18px]" aria-hidden />
</span>
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
) : initial ? (
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
{initial}
</span>
) : null}
</span>
)
}
/**
* 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.
* One notification: a tile that says at a glance what it is about and who sent
* it, the message itself, and what can be done with it. Unread ones are set in
* bold with a dot in the accent; marking and deleting come forward when the row
* is pointed at or focused, so a list reads as messages rather than as buttons.
*/
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
const unread = !notification.is_read
const answered = notification.response !== null
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
const asks = notification.type === 'boolean' && yesKey && noKey
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
return (
<div
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="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
<KindTile notification={notification} />
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0">
{notification.sender_avatar ? (
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
) : (
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="relative flex-shrink-0 h-7 flex items-center">
<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)}
</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 ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
{timeOf(notification.created_at, locale, t('common.justNow'))}
</span>
{unread && (
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
)}
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{notification.is_read ? (
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
<Mail className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText}
</p>
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'positive'
? 'bg-accent text-accent-text'
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<Check className="w-3.5 h-3.5" />
{t(notification.positive_text_key)}
</button>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${PILL} ${
notification.response === 'negative'
? 'bg-danger text-white'
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<X className="w-3.5 h-3.5" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
{asks && (
<div className="flex flex-wrap gap-2 mt-3">
<button
type="button"
onClick={handleNavigate}
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`}
onClick={() => handleRespond('positive')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'positive'
? 'bg-success-soft text-success'
: answered
? 'border border-edge text-content-faint'
: 'bg-accent text-accent-text hover:bg-accent-hover'
} disabled:cursor-default`}
>
{t(notification.navigate_text_key)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
<Check className="w-3.5 h-3.5" />
{t(yesKey)}
</button>
)}
</div>
<button
type="button"
onClick={() => handleRespond('negative')}
disabled={responding || answered}
className={`${ACTION} ${
notification.response === 'negative'
? 'bg-danger-soft text-danger'
: answered
? 'border border-edge text-content-faint'
: 'border border-edge text-content hover:bg-surface-secondary'
} disabled:cursor-default`}
>
<X className="w-3.5 h-3.5" />
{t(noKey)}
</button>
</div>
)}
{leads && (
<button
type="button"
onClick={handleNavigate}
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
>
{t(goKey)}
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
</button>
)}
</div>
</div>
)
@@ -0,0 +1,56 @@
import React, { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import InAppNotificationItem from './InAppNotificationItem';
import { groupByDay } from './notificationModel';
interface NotificationDayListProps {
notifications: InAppNotification[];
/** Closes the bell's panel after a notification led somewhere. */
onClose?: () => void;
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
compact?: boolean;
}
/**
* The notifications cut into days, each under its heading (Today, Yesterday,
* a weekday, then the date). On the page every day is a card of its own; in
* the bell's panel the headings stay at the top while the day scrolls by.
*/
export default function NotificationDayList({
notifications,
onClose,
compact = false,
}: NotificationDayListProps): React.ReactElement {
const { locale } = useTranslation();
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
return (
<div className={compact ? '' : 'flex flex-col gap-6'}>
{groups.map((group) => (
<section key={group.key} aria-label={group.label}>
<h2
className={
compact
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
}
>
{group.label}
</h2>
<div
className={
compact
? 'divide-y divide-edge-secondary'
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
}
>
{group.items.map((n) => (
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
*
* What a notification's tile shows, how the server's time is read, and how the
* list is cut into days under headings in the reader's language.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import type { InAppNotification } from '../../store/inAppNotificationStore';
import NotificationDayList from './NotificationDayList';
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
id,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: null,
sender_username: null,
sender_avatar: null,
recipient_id: 1,
title_key,
title_params: {},
text_key: 'notifications.empty',
text_params: {},
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: false,
created_at,
});
const NOW = new Date(2026, 9, 24, 15, 0);
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
describe('notificationModel', () => {
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
expect(kindOf('notif.collab_message.title')).toBe('message');
expect(kindOf('notif.replica_failure.title')).toBe('alert');
expect(kindOf('plugin.something.title')).toBe('other');
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
});
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
});
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
});
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
});
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
const groups = groupByDay(
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
'en',
NOW
);
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
['Today', [1, 2]],
['Thursday', [3]],
]);
});
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
expect(groupByDay([], 'en', NOW)).toEqual([]);
});
});
describe('NotificationDayList', () => {
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
@@ -0,0 +1,119 @@
import type { LucideIcon } from 'lucide-react';
import {
Backpack,
Bell,
BookmarkPlus,
CalendarDays,
Images,
ListChecks,
MessageCircle,
Mountain,
Plane,
Sparkles,
Ticket,
TriangleAlert,
} from 'lucide-react';
import type { InAppNotification } from '../../store/inAppNotificationStore';
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
export type NotificationKind =
| 'trip'
| 'message'
| 'booking'
| 'packing'
| 'todo'
| 'vacay'
| 'collection'
| 'photos'
| 'tour'
| 'update'
| 'alert'
| 'other';
const KIND_BY_TITLE: Record<string, NotificationKind> = {
'notif.trip_invite.title': 'trip',
'notif.trip_reminder.title': 'trip',
'notif.collab_message.title': 'message',
'notif.booking_change.title': 'booking',
'notif.packing_tagged.title': 'packing',
'notif.todo_due.title': 'todo',
'notif.vacay_invite.title': 'vacay',
'notif.vacay_share.title': 'vacay',
'notif.collection_invite.title': 'collection',
'notif.photos_shared.title': 'photos',
'notif.wanderer_source_hint.title': 'tour',
'notif.paddle_region_requested.title': 'tour',
'notif.version_available.title': 'update',
'notifications.versionAvailable.title': 'update',
'notif.replica_failure.title': 'alert',
'notifications.synologySessionCleared.title': 'alert',
};
export function kindOf(titleKey: string): NotificationKind {
return KIND_BY_TITLE[titleKey] ?? 'other';
}
/** The symbol of each kind and the tinted tile it sits on. */
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
};
/**
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
* which a bare `new Date()` would read as local time.
*/
export function createdAt(value: string): Date {
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
}
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
/**
* The heading of the day a notification belongs to, in the reader's language:
* today and yesterday by name, the rest of the week by weekday, anything older
* by its date (with the year when it is not this one).
*/
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
if (days <= 1) {
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
}
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
export interface NotificationGroup {
key: string;
label: string;
items: InAppNotification[];
}
/** The list cut into days, in the order it came (newest first). */
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
const groups: NotificationGroup[] = [];
for (const n of list) {
const date = createdAt(n.created_at);
const key = String(dayStart(date));
const current = groups[groups.length - 1];
if (current?.key === key) current.items.push(n);
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
}
return groups;
}
@@ -33,12 +33,6 @@ function mcpEndpointRow() {
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
const LAN_CONNECTOR = {
mcpUrl: 'http://192.168.1.20:3000/mcp',
deepLink: 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=x',
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
};
function enableAddons(ids: string[]) {
seedStore(useAddonStore, {
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
@@ -71,7 +65,6 @@ beforeEach(() => {
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
http.get('/api/integrations/claude-connector', () => HttpResponse.json(LAN_CONNECTOR)),
);
});
@@ -1026,25 +1019,4 @@ describe('IntegrationsTab – failure toasts', () => {
render(<IntegrationsTab />);
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-049: the Claude card explains why a LAN install cannot be added', async () => {
enableMcp();
render(<IntegrationsTab />);
expect(await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Open in Claude/ })).not.toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-050: the Claude card links a public install into Claude', async () => {
server.use(
http.get('/api/integrations/claude-connector', () =>
HttpResponse.json({ ...LAN_CONNECTOR, mcpUrl: 'https://trip.example.org/mcp' }),
),
);
enableMcp();
render(<IntegrationsTab />);
const link = await screen.findByRole('link', { name: /Open in Claude/ });
expect(link).toHaveAttribute('href', LAN_CONNECTOR.deepLink);
expect(link).toHaveAttribute('target', '_blank');
expect(screen.getByText('https://trip.example.org/mcp')).toBeInTheDocument();
});
});
@@ -8,7 +8,6 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
import DawarichConnectionSection from './DawarichConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import AiConnectionSection from './ai/AiConnectionSection'
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
import ApiKeysSection from './ApiKeysSection'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import ConfirmDialog from '../shared/ConfirmDialog'
@@ -45,7 +44,6 @@ export default function IntegrationsTab(): React.ReactElement {
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && <ClaudeConnectorCard />}
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
import type { AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { resetAllStores } from '../../../../tests/helpers/store';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
});
it('FE-COMP-AI-CONNECT-PANEL-008: "Using Claude?" shows with the MCP addon and the sharing details fold out', async () => {
it('FE-COMP-AI-CONNECT-PANEL-008: the sharing details fold out', async () => {
const user = userEvent.setup();
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
unmount();
seedStore(useAddonStore, { addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }] });
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
});
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
import { fs } from '../../shared/DialogShell';
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
import { CLAUDE_CONNECTOR_ANCHOR, type AiConnectMode } from './aiConnectModel';
import { type AiConnectMode } from './aiConnectModel';
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
interface AiConnectPanelProps {
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
const custom = useCustomConnect({ prefill, onConnected });
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
// The privacy line names whoever would receive the messages, once that is known.
const receivers: Record<AiConnectMode, string> = {
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
{mode === 'custom' && <CustomForm custom={custom} />}
<PrivacyNote provider={mode ? receivers[mode] : ''} />
{scope === 'user' && mcpEnabled && (
<button
type="button"
onClick={() =>
document.getElementById(CLAUDE_CONNECTOR_ANCHOR)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
className="self-start font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.connect.claudeLink')}
</button>
)}
</div>
);
}
@@ -1,63 +0,0 @@
// FE-COMP-CLAUDE-CONNECTOR-001 to FE-COMP-CLAUDE-CONNECTOR-003: adding TREK to Claude as a connector.
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
const repo = vi.hoisted(() => ({ claudeConnector: vi.fn() }));
const clipboard = vi.hoisted(() => ({ copyText: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../../utils/clipboard', () => clipboard);
const { default: ClaudeConnectorCard } = await import('./ClaudeConnectorCard');
const CONNECTOR = {
mcpUrl: 'https://trip.example.org/mcp',
deepLink:
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=https%3A%2F%2Ftrip.example.org%2Fmcp',
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>',
};
beforeEach(() => {
vi.clearAllMocks();
clipboard.copyText.mockResolvedValue(true);
});
describe('ClaudeConnectorCard', () => {
it('FE-COMP-CLAUDE-CONNECTOR-001: links a public install into Claude, with a QR code that is an image, not markup', async () => {
repo.claudeConnector.mockResolvedValue(CONNECTOR);
const { container } = render(<ClaudeConnectorCard />);
const link = await screen.findByRole('link', { name: /Open in Claude/ });
expect(link).toHaveAttribute('href', CONNECTOR.deepLink);
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
const qr = screen.getByAltText('QR code to add TREK in Claude');
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
// The QR code's own markup never lands in the page; icons may draw rects of their own.
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
expect(document.getElementById('claude-connector')).not.toBeNull();
});
it('FE-COMP-CLAUDE-CONNECTOR-002: copies the address and shows the tick for a moment', async () => {
const user = userEvent.setup();
repo.claudeConnector.mockResolvedValue(CONNECTOR);
render(<ClaudeConnectorCard />);
await user.click(await screen.findByRole('button', { name: 'Copy address' }));
expect(clipboard.copyText).toHaveBeenCalledWith(CONNECTOR.mcpUrl);
await waitFor(() => expect(document.querySelector('.text-success')).not.toBeNull());
});
it('FE-COMP-CLAUDE-CONNECTOR-003: a plain-http or LAN install gets the reason instead of a link, and a failed read only the description', async () => {
repo.claudeConnector.mockResolvedValueOnce({ ...CONNECTOR, mcpUrl: 'http://192.168.1.20:3000/mcp' });
const { unmount } = render(<ClaudeConnectorCard />);
expect(
await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')
).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
unmount();
repo.claudeConnector.mockRejectedValueOnce(new Error('404'));
render(<ClaudeConnectorCard />);
expect(screen.getByText('Use TREK right inside Claude: add it as a connector, sign in, done.')).toBeInTheDocument();
await waitFor(() => expect(repo.claudeConnector).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -1,98 +0,0 @@
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsCard, SettingsHint } from '../settingsKit';
import { CLAUDE_CONNECTOR_ANCHOR } from './aiConnectModel';
import { useClaudeConnector } from './useClaudeConnector';
type ClaudeConnector = ReturnType<typeof useClaudeConnector>;
/** The way in: the button that adds TREK on claude.ai, the address to copy, and the QR code for a phone. */
function ConnectorLinks({
connector,
qrSrc,
copied,
copyUrl,
}: Omit<ClaudeConnector, 'connector' | 'reachable'> & { connector: NonNullable<ClaudeConnector['connector']> }) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-5">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-3">
<div className="flex">
<a
href={connector.deepLink}
target="_blank"
rel="noopener noreferrer"
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
<ExternalLink size={14} strokeWidth={2.2} /> {t('assistant.claude.open')}
</a>
</div>
<div className="flex items-center gap-2">
<code
dir="ltr"
className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-mono text-content"
style={fs(12, 'body')}
>
{connector.mcpUrl}
</code>
<Tooltip label={copied ? t('common.copied') : t('assistant.claude.copyUrl')}>
<button
type="button"
onClick={() => void copyUrl()}
aria-label={t('assistant.claude.copyUrl')}
className={SETTINGS_ICON_BUTTON}
>
{copied ? (
<Check size={15} strokeWidth={2.2} className="text-success" />
) : (
<Copy size={15} strokeWidth={2} />
)}
</button>
</Tooltip>
</div>
</div>
<figure className="m-0 flex flex-none flex-col items-center gap-1.5 max-sm:hidden">
<img
src={qrSrc}
alt={t('assistant.claude.qrAlt')}
width={112}
height={112}
className="rounded-[10px] shadow-sm ring-1 ring-edge-faint"
/>
<figcaption className="text-content-faint" style={fs(11)}>
{t('assistant.claude.scan')}
</figcaption>
</figure>
</div>
);
}
/**
* "Connect with Claude": TREK as a connector inside Claude, over the MCP
* server and its OAuth. One button adds it on claude.ai; the QR code does the
* same from a phone, so it only shows where a phone is not already in hand.
*/
export default function ClaudeConnectorCard(): React.ReactElement {
const { t } = useTranslation();
const { connector, reachable, ...links } = useClaudeConnector();
// Null keeps the card to its band until the address is known.
let body: React.ReactNode = null;
if (connector && reachable) body = <ConnectorLinks connector={connector} {...links} />;
else if (connector) body = <SettingsHint>{t('assistant.claude.notPublic')}</SettingsHint>;
return (
<SettingsCard
id={CLAUDE_CONNECTOR_ANCHOR}
icon={MessagesSquare}
title={t('assistant.claude.title')}
hint={t('assistant.claude.description')}
>
{body}
</SettingsCard>
);
}
@@ -9,9 +9,6 @@ import { OnlineOnlyError } from '../../../repo/onlineOnly';
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
/** Where the Claude connector card sits, for the "Using Claude?" line to jump to. */
export const CLAUDE_CONNECTOR_ANCHOR = 'claude-connector';
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
export function refusalCode(err: unknown): string | null {
if (err instanceof OnlineOnlyError) return 'offline';
@@ -1,56 +0,0 @@
import type { AiClaudeConnectorResponse } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { copyText } from '../../../utils/clipboard';
import { isPublicHttpsUrl, svgDataUrl } from './aiConnectModel';
/**
* Adding TREK to Claude as a connector (no AI connection of TREK's own
* involved): the MCP address, the claude.ai link that adds it, and a QR code
* for doing it from a phone. Claude reaches TREK from the internet, so on a
* LAN or plain-http install the card explains that instead of offering a link
* that would fail after the user did everything right.
*/
export function useClaudeConnector() {
const [connector, setConnector] = useState<AiClaudeConnectorResponse | null>(null);
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
aiConnectionsRepo.claudeConnector().then(
(next) => {
if (!cancelled) setConnector(next);
},
() => {
// Without an answer the card keeps to its description; nothing to link to.
if (!cancelled) setConnector(null);
}
);
return () => {
cancelled = true;
};
}, []);
useEffect(
() => () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
},
[]
);
const copyUrl = async () => {
if (!connector || !(await copyText(connector.mcpUrl))) return;
setCopied(true);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
};
return {
connector,
reachable: connector ? isPublicHttpsUrl(connector.mcpUrl) : false,
qrSrc: connector ? svgDataUrl(connector.qrSvg) : '',
copied,
copyUrl,
};
}
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
it('narrows the set to one archetype from the style chips', () => {
panel();
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
expect(
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
import { useStudioTheme } from './useStudioTheme';
/**
* The Theme section of the rail: twelve built-in looks, the palette, the
* The Theme section of the rail: seventeen built-in looks, the palette, the
* typefaces and the motifs of the one being put together, and applying it to
* the book or to the page that is open.
*
@@ -83,6 +83,13 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
</ul>
)
}
if (block.kind === 'h5') {
return (
<h5 key={idx} className="text-caption font-semibold mt-3 mb-1 text-m-ink">
{block.text}
</h5>
);
}
if (block.kind === 'h4') {
return (
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
@@ -1,15 +1,10 @@
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
import MDawarichConnectionSection from './MDawarichConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MAiConnectionSection from './MAiConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
import { useClaudeConnector } from '../../../components/Settings/ai/useClaudeConnector'
import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConnectModel'
/**
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
{mcpEnabled && <MClaudeConnectorCard />}
{mcpEnabled && <MSettingsMcp />}
</>
)
}
/**
* "Connect with Claude" on the phone: the button and the address only. The QR
* code is for reaching a phone from a desktop, and this is the phone.
*/
function MClaudeConnectorCard() {
const { t } = useTranslation()
const { connector, reachable, copied, copyUrl } = useClaudeConnector()
return (
<div id={CLAUDE_CONNECTOR_ANCHOR}>
<MSetCard title={t('assistant.claude.title')} icon={MessagesSquare} className="mt-3">
<MSetHint className="mb-3">{t('assistant.claude.description')}</MSetHint>
{connector && !reachable && <MSetHint>{t('assistant.claude.notPublic')}</MSetHint>}
{connector && reachable && (
<div className="flex flex-wrap gap-2">
<MSetButton onClick={() => window.open(connector.deepLink, '_blank', 'noopener,noreferrer')}>
<ExternalLink size={14} /> {t('assistant.claude.open')}
</MSetButton>
<MSetButton variant="ghost" onClick={() => void copyUrl()}>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? t('common.copied') : t('assistant.claude.copyUrl')}
</MSetButton>
</div>
)}
</MSetCard>
</div>
)
}
+18 -20
View File
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
import EmptyState from '../components/shared/EmptyState'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import NotificationDayList from '../components/Notifications/NotificationDayList'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
</button>
</div>
{/* Notification list */}
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div>
) : displayed.length === 0 ? (
{/* The list, one card per day. */}
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
</div>
) : displayed.length === 0 ? (
<div className="rounded-2xl border border-edge bg-surface-card">
<EmptyState scene="notifications" title={t('notifications.empty')} />
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
</div>
) : (
<NotificationDayList notifications={displayed} />
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
</div>
)}
</div>
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
</div>
)}
</div>
<ConfirmDialog
isOpen={confirmingDeleteAll}
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
});
});
+1 -9
View File
@@ -2,7 +2,6 @@ import {
aiAdminConnectionResponseSchema,
aiAdminSettingsSchema,
aiAuthStartResponseSchema,
aiClaudeConnectorResponseSchema,
aiConnectionViewSchema,
aiModelListResponseSchema,
aiStatusResponseSchema,
@@ -14,7 +13,7 @@ import {
type AiOpenrouterFinishRequest,
type AiPrivacyAckRequest,
} from '@trek/shared';
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
import { aiAdminApi, aiApi } from '../api/assistant';
import { requireOnline } from './onlineOnly';
/**
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
requireOnline();
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
},
// ── Claude connector ───────────────────────────────────────────────────────
async claudeConnector() {
requireOnline();
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
},
};
+2 -2
View File
@@ -22,11 +22,11 @@ describe('parseReleaseNotes', () => {
]);
});
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
it('FE-UTIL-RELNOTES-003: lines are trimmed, markers need their space and #### heads a feature', () => {
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
{ kind: 'h3', text: 'Spaced' },
{ kind: 'p', text: '-no space' },
{ kind: 'p', text: '#### deep' },
{ kind: 'h5', text: 'deep' },
]);
});
+7 -4
View File
@@ -1,5 +1,5 @@
/** One block of a release body: a heading, a paragraph, or a run of list items. */
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'h5' | 'p'; text: string } | { kind: 'ul'; items: string[] };
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
export function escapeReleaseHtml(str: string): string {
@@ -7,8 +7,8 @@ export function escapeReleaseHtml(str: string): string {
}
/**
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
* Splits a GitHub release body into the blocks both admin shells render: `## `, `### ` and
* `#### ` headings (the version, a section such as Added, a feature inside it), `- ` or `* ` list items gathered into one list until anything else breaks the
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
* text stays raw; each shell formats it inline itself.
*/
@@ -30,7 +30,10 @@ export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
continue;
}
if (trimmed.startsWith('### ')) {
if (trimmed.startsWith('#### ')) {
flushList();
blocks.push({ kind: 'h5', text: trimmed.slice(5) });
} else if (trimmed.startsWith('### ')) {
flushList();
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
} else if (trimmed.startsWith('## ')) {
@@ -179,12 +179,12 @@ describe('applying a theme', () => {
});
describe('the Theme panel', () => {
it('FE-STUDIO-THEME-007: shows twelve themes, applies the one picked and says so', () => {
it('FE-STUDIO-THEME-007: shows seventeen themes, applies the one picked and says so', () => {
render(<StudioThemePanel t={t} />);
const cards = screen
.getAllByRole('button', { pressed: false })
.filter((b) => b.classList.contains('st-theme-card'));
expect(cards).toHaveLength(11);
expect(cards).toHaveLength(16);
fireEvent.click(screen.getByTitle('studio.theme.descr.riviera'));
fireEvent.click(screen.getByText('studio.theme.applyBook'));
expect(doc().theme?.id).toBe('riviera');
+1 -1
View File
@@ -17,5 +17,5 @@
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006,
"src/nest/vacay/vacay.service.ts": 1416
"src/nest/vacay/vacay.service.ts": 1409
}
@@ -46,6 +46,11 @@ Recipes, for a cover built or tweaked by hand, as JSON: each object one element
- grid-badge: [{"kind":"photo","photoId":"cover","frame":[-2,-2,50.8,50.8]},{"kind":"photo","photoId":"2","frame":[51.2,-2,50.8,50.8]},{"kind":"photo","photoId":"3","frame":[-2,51.2,50.8,50.8]},{"kind":"photo","photoId":"4","frame":[51.2,51.2,50.8,50.8]},{"lockup":"circle-badge","box":[29,29,42,42]}], a cross of paper 2.4 % (5 mm) wide, the badge off every photo's focal point.
- minimal-serif: [{"kind":"photo","photoId":"cover","frame":[8,8,84,53],"border":{"width":0.3,"color":"ink"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"leading":1,"frame":[8,64,84,17]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"ink","size":11,"frame":[8,82,84,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[28,88.4,20,0.15]},{"kind":"shape","shape":"sun","token":"accent","frame":[48.5,87,3,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[52,88.4,20,0.15]},{"kind":"text","text":"2026","size":8,"frame":[8,92,84,3]}]
- stamp-collage: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"checker","fg":"accent","bg":"paper","scale":1,"angle":0},"frame":[-2,-2,104,36]},{"kind":"photo","photoId":"cover","frame":[8,19,26,31],"rotation":-4,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"photo","photoId":"2","frame":[37,17,26,34],"rotation":3,"mask":"arch-tall","cutout":{"width":1.8,"color":"#ffffff"}},{"kind":"photo","photoId":"3","frame":[66,19,26,31],"rotation":-3,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"weight":800,"transform":"upper","align":"left","frame":[8,62,52,18]},{"kind":"text","text":"May 2026","size":9,"frame":[8,81,52,4]},{"lockup":"postmark","box":[62,60,30,30]}]
- riso: [{"kind":"shape","shape":"ellipse","token":"accent2","opacity":0.85,"frame":[8,46,52,52]},{"kind":"shape","shape":"ellipse","paint":{"type":"pattern","pattern":"dots","fg":"accent","bg":null,"scale":0.4,"angle":45},"frame":[11,44,52,52]},{"kind":"shape","shape":"rect","token":"accent","frame":[37,50,52,42],"rotation":2},{"kind":"photo","photoId":"cover","frame":[39,48,52,42],"rotation":2},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"transform":"upper","leading":0.92,"align":"left","shadow":{"dx":1.6,"dy":1.6,"color":"accent","alpha":1},"frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"highlight":{"color":"accent2"},"frame":[8,88,20,4]}], the block and the discs a few mm out of register.
- editorial: [{"kind":"photo","photoId":"cover","frame":[-2,37,104,65]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":96,"weight":800,"transform":"upper","leading":0.9,"frame":[8,8,84,19]},{"kind":"shape","shape":"rect","token":"ink","frame":[8,27.5,84,0.2]},{"kind":"text","text":"May 2026","size":8,"align":"left","frame":[8,29,40,3]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,74,44,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"paper","size":16,"align":"left","frame":[12,78,36,10]}], the block off the photo's focal point.
- vintage-poster: [{"kind":"shape","shape":"rect","fill":null,"strokeToken":"ink","strokeWidth":0.6,"frame":[1.7,1.7,96.6,96.6]},{"kind":"shape","shape":"rect","token":"accent3","frame":[3.3,3.3,93.4,58]},{"kind":"shape","shape":"sunburst-24","token":"accent","opacity":0.5,"frame":[24,6,52,52]},{"kind":"shape","shape":"ellipse","token":"accent","frame":[34,16,32,32]},{"kind":"shape","shape":"mountain","token":"accent2","frame":[3.3,32,64,29.3]},{"kind":"shape","shape":"mountain","token":"ink","opacity":0.8,"frame":[36,40,60.7,21.3]},{"kind":"photo","photoId":"cover","frame":[38,14,24,44],"mask":"arch-tall","cutout":{"width":2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"transform":"upper","leading":0.92,"frame":[8,66,84,20]},{"kind":"text","text":"May 2026","size":9,"frame":[8,88,84,4]}]
- night-film: [{"background":"ink"},{"kind":"text","text":"May 2026","font":"mono","token":"accent","size":8,"tracking":0.2,"align":"left","frame":[8,8,40,3]},{"kind":"photo","photoId":"cover","frame":[-2,14,104,40]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":72,"transform":"upper","tracking":0.14,"align":"left","frame":[8,58,84,16]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,76,16,0.4]},{"kind":"shape","shape":"rect","token":"paper","opacity":0.12,"frame":[-2,80,104,18]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,81.5,104,1]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,95.5,104,1]},{"kind":"photo","photoId":"2","frame":[8,84,26,10]},{"kind":"photo","photoId":"3","frame":[37,84,26,10]},{"kind":"photo","photoId":"4","frame":[66,84,26,10]}], paper and ink swapped in a dark theme.
- field-notes: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"grid","fg":"accent3","bg":"accent2","scale":0.6,"angle":0},"frame":[4,4,92,92],"rotation":-1.5},{"kind":"shape","shape":"leaf-long","token":"accent3","frame":[2,40,9,40],"rotation":-20},{"kind":"photo","photoId":"cover","frame":[10,10,48,40],"rotation":-3,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"shape","shape":"rect","token":"accent2","opacity":0.72,"edge":"zigzag","edgeSize":1,"frame":[24,8,19,3.3],"rotation":4},{"kind":"photo","photoId":"2","frame":[62,17,28,27],"rotation":4,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":60,"align":"left","frame":[8,62,46,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","token":"accent","size":26,"align":"left","frame":[8,80,46,8]},{"kind":"text","text":"May 2026","font":"mono","size":8,"align":"left","frame":[8,89,46,3]}]
Lively spreads, in percent of the double page (the fold at 50); photos 1 to 4 are the page's, entry its entry id:
- script overlay: [{"kind":"photo","photoId":"1","focalX":0.3,"frame":[-1,-2,102,104]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"fontRole":"script","size":120,"color":"#ffffff","outline":{"color":"#1a1a1a","width":2.9},"frame":[55,26,40,30],"rotation":-6},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"token":"ink","size":9,"highlight":{"color":"paper"},"frame":[57,62,36,4]}], the photo's detail off the fold and the word.
- collage: [{"kind":"photo","photoId":"1","frameStyle":"polaroid","frame":[6,10,26,36],"rotation":-5},{"kind":"photo","photoId":"2","frameStyle":"white","frame":[22,42,24,32],"rotation":4},{"kind":"photo","photoId":"3","frameStyle":"tape","frame":[56,12,30,40],"rotation":-3},{"kind":"photo","photoId":"4","frameStyle":"white","frame":[66,52,26,34],"rotation":6},{"kind":"sticker","stickerId":"motif","frame":[39,8,7,10],"rotation":8}], up to 3 prints a page turned 3 to 7 degrees either way, none across the fold or over another's focal point.
@@ -1,26 +0,0 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { ClaudeConnectorService } from './claude-connector.service';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { aiClaudeConnectorResponseSchema, type AiClaudeConnectorResponse } from '@trek/shared';
/**
* /api/integrations/claude-connector — what the settings card needs to add
* TREK to claude.ai as a custom connector. It follows the MCP addon (the
* connector is the MCP endpoint), not the in-app assistant's addon.
*/
@Controller('api/integrations/claude-connector')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.MCP, 'MCP')
export class ClaudeConnectorController {
constructor(private readonly connector: ClaudeConnectorService) {}
@Get()
@ResponseContract(aiClaudeConnectorResponseSchema)
async get(): Promise<AiClaudeConnectorResponse> {
return await this.connector.describe();
}
}
@@ -1,31 +0,0 @@
import { OauthService } from './oauth.service';
import { Injectable } from '@nestjs/common';
import type { AiClaudeConnectorResponse } from '@trek/shared';
import QRCode from 'qrcode';
/** claude.ai's "add custom connector" dialog, prefilled through its query string. */
const CLAUDE_ADD_CONNECTOR_URL = 'https://claude.ai/customize/connectors';
/**
* "Connect with Claude": the link that opens claude.ai's custom-connector
* dialog with this instance's MCP endpoint filled in, and the same link as a QR
* code for the phone. claude.ai then runs the ordinary OAuth flow against
* /oauth/authorize; nothing here touches the user's data or calls Anthropic.
*/
@Injectable()
export class ClaudeConnectorService {
constructor(private readonly oauth: OauthService) {}
async describe(): Promise<AiClaudeConnectorResponse> {
const mcpUrl = `${this.oauth.mcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
const params = new URLSearchParams({
modal: 'add-custom-connector',
connectorName: 'TREK',
connectorUrl: mcpUrl,
});
const deepLink = `${CLAUDE_ADD_CONNECTOR_URL}?${params.toString()}`;
const qrSvg = await QRCode.toString(deepLink, { type: 'svg', margin: 1 });
return { mcpUrl, deepLink, qrSvg };
}
}
+2 -6
View File
@@ -8,8 +8,6 @@ import { createMcpAddonGate } from '../addons/mcp-addon-gate';
import { AuditModule } from '../audit/audit.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { ClaudeConnectorController } from './claude-connector.controller';
import { ClaudeConnectorService } from './claude-connector.service';
import { OauthApiController } from './oauth-api.controller';
import { CimdClientResolver } from './oauth-cimd';
import { OauthPublicController } from './oauth-public.controller';
@@ -49,8 +47,7 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
* swapped provider too.
*
* Client ID Metadata Documents (oauth-cimd.ts) resolve through the injected
* CimdClientResolver; ClaudeConnectorController hands the settings page the
* claude.ai link to this instance's MCP endpoint.
* CimdClientResolver.
*
* Exports OauthService for AdminController (admin OAuth-session panel) and the
* MCP transport's token verification.
@@ -73,11 +70,10 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
SchedulingModule,
MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users]),
],
controllers: [OauthPublicController, OauthApiController, ClaudeConnectorController],
controllers: [OauthPublicController, OauthApiController],
providers: [
OauthService,
CimdClientResolver,
ClaudeConnectorService,
TrekClientsStore,
TrekOAuthProvider,
OauthTokenRetentionJob,
+32
View File
@@ -69,3 +69,35 @@ export function shiftIsoDate(isoDate: string, days: number): string {
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export const CACHE_TTL = 24 * 60 * 60 * 1000;
export const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
export const YEAR_RE = /^\d{4}$/;
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
export const COLORS = [
'#6366f1',
'#ec4899',
'#14b8a6',
'#8b5cf6',
'#ef4444',
'#3b82f6',
'#22c55e',
'#06b6d4',
'#f43f5e',
'#a855f7',
'#10b981',
'#0ea5e9',
'#64748b',
'#be185d',
'#0d9488',
];
+55 -63
View File
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
import {
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
shiftIsoDate, windowEndYear,
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
} from './vacay.helpers';
// ---------------------------------------------------------------------------
@@ -125,26 +125,6 @@ export interface VacayShare {
created_at?: string;
}
const CACHE_TTL = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
// A country's holidays for one year are a few kilobytes; two megabytes means the
// provider is misbehaving and we would rather report the usual error than buffer it.
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
// else has no business reaching the URL path.
const YEAR_RE = /^\d{4}$/;
const COUNTRY_RE = /^[A-Za-z]{2}$/;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
/**
* Vacay domain service — owns the vacay SQL, now through the ten vacay
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
@@ -1020,37 +1000,38 @@ export class VacayService {
}
async addYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// A duplicate year is a no-op (the legacy blanket try/catch was written for
// exactly this constraint hit); real errors now propagate instead of being
// swallowed. The insert + per-user seeding runs atomically.
const exists = await this.years.exists(planId, year);
if (!exists) {
await this.uow.transactional(async () => {
await this.years.insertYear(planId, year);
const plan = await this.plans.findById(planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
if (prevConfig) {
const used = await this.usedDays(u.id, planId, year - 1);
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
carriedOver = Math.max(0, total - used);
}
}
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
}
});
}
// A duplicate year is a no-op (the legacy blanket try/catch was written for exactly this
// constraint hit); real errors propagate. The insert + per-user seeding runs atomically.
if (!(await this.years.exists(planId, year))) await this.openYear(planId, year);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
/** The year's row and every member's allowance for it, with no broadcast: the caller sends it after its commit. */
private async openYear(planId: number, year: number): Promise<void> {
await this.uow.transactional(async () => {
await this.years.insertYear(planId, year);
const plan = await this.plans.findById(planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
if (prevConfig) {
const used = await this.usedDays(u.id, planId, year - 1);
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
carriedOver = Math.max(0, total - used);
}
}
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
}
});
}
async deleteYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// Year removal deletes across four tables and recomputes the next year's
// carry-over — atomic, so a failure can't leave entries without their year.
@@ -1139,25 +1120,32 @@ export class VacayService {
const knd = normalizeKind(kind);
const weekendBlocked = plan ? isBlockedWeekend(plan, date) : false;
const existing = await this.entries.findByUserDatePlan(userId, date, planId);
if (existing) {
// Clicking the exact same day again (same type AND same fraction) clears it;
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
await this.entries.deleteById(existing.id);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.updateFractionKind(existing.id, frac, knd);
// Clicking the exact same day again (same type AND same fraction) clears it;
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing && existing.fraction === frac && (existing.kind || 'vacation') === knd) {
await this.entries.deleteById(existing.id);
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one there does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
const opened = await this.uow.transactional(async () => {
if (existing) await this.entries.updateFractionKind(existing.id, frac, knd);
else await this.entries.insertEntry(planId, userId, date, '', frac, knd);
return this.ensureYearOf(planId, userId, date);
});
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
return { action: existing ? 'updated' : 'added', fraction: frac, kind: knd };
}
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
private async ensureYearOf(planId: number, userId: number, date: string): Promise<boolean> {
const [y, m, d] = date.split('-').map(Number);
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
if (await this.years.exists(planId, year)) return false;
await this.openYear(planId, year);
return true;
}
/**
@@ -1167,6 +1155,7 @@ export class VacayService {
*/
async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined, fraction?: unknown): Promise<{ action: string; fraction?: number }> {
const frac = normalizeFraction(fraction);
let opened = false;
const result = await this.uow.transactional(async () => {
const existing = await this.companyHolidays.findByPlanAndDate(planId, date); // VC118
if (existing && existing.fraction === frac) {
@@ -1179,8 +1168,11 @@ export class VacayService {
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
}
await this.makeRoomForCompanyHoliday(planId, date, frac);
const owner = await this.plans.findOwnerId(planId);
if (owner) opened = await this.ensureYearOf(planId, owner.owner_id, date);
return { action: existing ? 'updated' : 'added', fraction: frac };
});
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId);
return result;
}
-30
View File
@@ -160,34 +160,4 @@ describe('OAuth e2e (real guards + temp SQLite)', () => {
expect(cookie.status).toBe(201);
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
});
describe('GET /api/integrations/claude-connector', () => {
const MCP_URL = 'https://mcp.test/mcp';
it('404 { error } while the MCP addon is off, even without a session', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/integrations/claude-connector');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'MCP addon is not enabled' });
});
it('401 without a session', async () => {
const res = await request(server).get('/api/integrations/claude-connector');
expect(res.status).toBe(401);
});
it('200 with the MCP endpoint, the claude.ai link that adds it and that link as a QR code', async () => {
const res = await request(server).get('/api/integrations/claude-connector').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.mcpUrl).toBe(MCP_URL);
const link = new URL(res.body.deepLink);
expect(link.origin + link.pathname).toBe('https://claude.ai/customize/connectors');
expect(Object.fromEntries(link.searchParams)).toEqual({
modal: 'add-custom-connector',
connectorName: 'TREK',
connectorUrl: MCP_URL,
});
expect(res.body.qrSvg).toMatch(/^<svg[\s\S]*<\/svg>\s*$/);
});
});
});
@@ -602,14 +602,22 @@ const vacayCases: ParityCase[] = [
needs: ['vacay'],
args: () => ({ date: '2027-06-01' }),
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'toggle_company_holiday',
needs: ['vacay'],
args: () => ({ date: '2027-06-02' }),
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
expect: [{ event: 'vacay:update', user: 'actor' }],
// 2027 is not a year of the plan yet, so the day opens it too.
expect: [
{ event: 'vacay:settings', user: 'actor' },
{ event: 'vacay:update', user: 'actor' },
],
},
{
tool: 'update_vacay_stats',
@@ -130,7 +130,7 @@ describe('the art director in the Studio', () => {
const paint = i.paint as { pattern?: string } | undefined;
return paint?.pattern ? [paint.pattern] : [];
});
expect(patterns).toEqual(['checker']);
expect(patterns).toEqual(['checker', 'dots', 'grid']);
for (const id of patterns) expect(BOOK_PATTERNS as readonly string[]).toContain(id);
});
@@ -154,7 +154,7 @@ describe('the art director in the Studio', () => {
});
it('ASTRCP-004: keeps to a budget the cache carries every turn, without dashes', () => {
expect(BOOK_RECIPES_PROMPT.length).toBeLessThan(9500);
expect(BOOK_RECIPES_PROMPT.length).toBeLessThan(14500);
expect(BOOK_RECIPES_PROMPT.split('\n').length).toBeLessThanOrEqual(24);
expect(BOOK_RECIPES_PROMPT).not.toMatch(/[\u2013\u2014]/);
expect(BOOK_RECIPES_PROMPT).not.toContain(' - ');
@@ -1,67 +0,0 @@
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
import { ClaudeConnectorController } from '../../../src/nest/oauth/claude-connector.controller';
import { ClaudeConnectorService } from '../../../src/nest/oauth/claude-connector.service';
import { OauthService } from '../../../src/nest/oauth/oauth.service';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Test } from '@nestjs/testing';
import { aiClaudeConnectorResponseSchema } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
async function service(mcpSafeUrl: string): Promise<ClaudeConnectorService> {
const moduleRef = await Test.createTestingModule({
providers: [ClaudeConnectorService, { provide: OauthService, useValue: { mcpSafeUrl: () => mcpSafeUrl } }],
}).compile();
return moduleRef.get(ClaudeConnectorService);
}
describe('ClaudeConnectorService', () => {
it('CLAUDE-001: links claude.ai to the MCP endpoint of this instance and encodes it once', async () => {
const res = await (await service('https://trek.example.test')).describe();
expect(res.mcpUrl).toBe('https://trek.example.test/mcp');
expect(res.deepLink).toBe(
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK' +
'&connectorUrl=https%3A%2F%2Ftrek.example.test%2Fmcp',
);
expect(aiClaudeConnectorResponseSchema.parse(res)).toEqual(res);
});
it('CLAUDE-002: trailing slashes on the configured URL do not double the path', async () => {
expect((await (await service('https://trek.example.test/sub///')).describe()).mcpUrl).toBe(
'https://trek.example.test/sub/mcp',
);
});
it('CLAUDE-003: the QR code comes as an SVG document', async () => {
const { qrSvg } = await (await service('https://trek.example.test')).describe();
expect(qrSvg.startsWith('<svg')).toBe(true);
expect(qrSvg.trimEnd().endsWith('</svg>')).toBe(true);
});
});
describe('ClaudeConnectorController', () => {
it('CLAUDE-004: answers what the service describes', async () => {
const answer = { mcpUrl: 'https://t.example/mcp', deepLink: 'https://claude.ai/x', qrSvg: '<svg/>' };
const describeConnector = vi.fn().mockResolvedValue(answer);
const controller = new ClaudeConnectorController({
describe: describeConnector,
} as unknown as ClaudeConnectorService);
expect(await controller.get()).toBe(answer);
expect(describeConnector).toHaveBeenCalledOnce();
});
it('CLAUDE-005: follows the MCP addon, checks it before the session, and declares its contract', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ClaudeConnectorController)).toEqual([AddonGuard, JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, ClaudeConnectorController)).toEqual({
addonId: ADDON_IDS.MCP,
label: 'MCP',
});
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, ClaudeConnectorController.prototype.get)).toBe(
aiClaudeConnectorResponseSchema,
);
});
});
@@ -74,9 +74,9 @@ describe('decorated templates in list_book_layouts', () => {
const all = json(await mcp.listLayouts({ journeyId: journey.id }, ctx(viewer.id)));
const single = all.decorated.single as Row[];
const inner = all.decorated.inner as Row[];
expect(single).toHaveLength(21);
expect(inner).toHaveLength(16);
expect(new Set(single.map((r) => r.archetype)).size).toBe(7);
expect(single).toHaveLength(48);
expect(inner).toHaveLength(60);
expect(new Set(single.map((r) => r.archetype)).size).toBe(12);
expect(single.find((r) => r.id === 'grid-badge-circle')).toMatchObject({
archetype: 'grid-badge',
photoSlots: 4,
@@ -86,7 +86,7 @@ describe('decorated templates in list_book_layouts', () => {
expect(all.notes.decorated).toMatch(/whole designed page/);
const narrowed = json(await mcp.listLayouts({ journeyId: journey.id, role: 'single' }, ctx(owner.id)));
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([21, []]);
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([48, []]);
});
});
+1 -4
View File
@@ -1614,21 +1614,18 @@ describe('OauthModule', () => {
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
const { ClaudeConnectorController } = await import('../../../src/nest/oauth/claude-connector.controller');
const { ClaudeConnectorService } = await import('../../../src/nest/oauth/claude-connector.service');
const { CimdClientResolver } = await import('../../../src/nest/oauth/oauth-cimd');
const { TrekClientsStore, TrekOAuthProvider } = await import('../../../src/nest/oauth/oauth-sdk.provider');
const { OauthTokenRetentionJob } = await import('../../../src/nest/oauth/oauth-token-retention.job');
const controllers = Reflect.getMetadata('controllers', OauthModule);
const providers = Reflect.getMetadata('providers', OauthModule);
expect(controllers).toEqual([OauthPublicController, OauthApiController, ClaudeConnectorController]);
expect(controllers).toEqual([OauthPublicController, OauthApiController]);
// RateLimitService is deliberately absent: it comes from the global
// RateLimitModule so all consumers share one set of counters.
expect(providers).toEqual([
Svc,
CimdClientResolver,
ClaudeConnectorService,
TrekClientsStore,
TrekOAuthProvider,
OauthTokenRetentionJob,
@@ -1875,3 +1875,38 @@ describe('quirk fixes', () => {
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
});
});
describe('a day written for a year the plan has not opened', () => {
it('VACAY-SVC-079: adds that year with the member allowance, so the day shows in the calendar', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
expect(await svc.listYears(plan.id)).not.toContain(2031);
const result = await svc.toggleEntry(user.id, plan.id, '2031-01-06', 1, 'vacation');
expect(result.action).toBe('added');
expect(await svc.listYears(plan.id)).toContain(2031);
expect(await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 })).toBeDefined();
});
it('VACAY-SVC-080: taking the day away again keeps the year it opened', async () => {
const { user, plan } = await setupUserWithPlan();
await allowWeekends(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.deleteYear(plan.id, 2032, undefined);
const before = await svc.listYears(plan.id);
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
expect(await svc.listYears(plan.id)).toEqual([...before, 2032].sort((a, b) => a - b));
});
it('VACAY-SVC-081: a company holiday in a year not opened yet adds that year too', async () => {
const { plan } = await setupUserWithPlan();
await svc.toggleCompanyHoliday(plan.id, '2033-12-25', 'Christmas', undefined);
expect(await svc.listYears(plan.id)).toContain(2033);
});
});
-10
View File
@@ -224,13 +224,3 @@ export const aiAdminSettingsSchema = z
})
.strict();
export type AiAdminSettings = z.infer<typeof aiAdminSettingsSchema>;
// ── Claude connector: /api/integrations/claude-connector ───────────────────
/** GET /api/integrations/claude-connector: adding TREK's MCP server in Claude, as a link and a QR code. */
export const aiClaudeConnectorResponseSchema = z.object({
mcpUrl: z.url(),
deepLink: z.url(),
qrSvg: z.string(),
});
export type AiClaudeConnectorResponse = z.infer<typeof aiClaudeConnectorResponseSchema>;
+11 -1
View File
@@ -1,5 +1,5 @@
/**
* The seven cover archetypes a decorated book starts from: the Studio's cover
* The twelve cover archetypes a decorated book starts from: the Studio's cover
* templates are drawn in them, and the assistant's art direction names one.
*
* - spine-band: a bleed photo with a coloured band on the binding side.
@@ -9,6 +9,11 @@
* - grid-badge: a two by two photo grid with a round title badge at its centre.
* - minimal-serif: a white page, a large photo with a hairline, a very large thin serif title.
* - stamp-collage: photos in arches and stamps over a checker band, with a round postmark.
* - riso: a risograph print, spot inks overprinted a little out of register, halftone dots, bold type.
* - editorial: a travel magazine, a huge masthead, thin rules, an issue line, a photo edge to edge.
* - vintage-poster: a 1950s travel poster, flat layered mountains under a sun, a stacked title, a thin frame.
* - night-film: a dark cinematic page, a letterboxed photo, a film strip with sprocket holes, a neon accent.
* - field-notes: a traveller's notebook, squared paper, prints taped in at an angle, pressed leaves.
*/
export const BOOK_COVER_ARCHETYPES = [
'spine-band',
@@ -18,5 +23,10 @@ export const BOOK_COVER_ARCHETYPES = [
'grid-badge',
'minimal-serif',
'stamp-collage',
'riso',
'editorial',
'vintage-poster',
'night-film',
'field-notes',
] as const;
export type BookCoverArchetype = (typeof BOOK_COVER_ARCHETYPES)[number];
@@ -32,6 +32,11 @@ export const BOOK_COVER_ARCHETYPE_LINES: Readonly<Record<BookCoverArchetype, str
'grid-badge': 'four photos in a grid with a white cross, a round title badge at its centre',
'minimal-serif': 'a white page, a large photo with a hairline, a very large thin serif title and a sun ornament',
'stamp-collage': 'photos in arches and stamps over a checker band, a round postmark',
riso: 'spot inks overprinted out of register, halftone dots, a duotone photo, a heavy title with an ink shadow',
editorial: 'a magazine cover: a huge masthead, a thin rule and an issue line, a photo edge to edge',
'vintage-poster': 'a retro travel poster: flat mountains under a sun, a photo in an arch, a stacked title',
'night-film': 'a near black page, a letterboxed photo, a film strip with sprocket holes, neon capitals',
'field-notes': 'squared notebook paper, prints taped in at an angle, pressed leaves, a script line',
};
/** The pages a lively or loud book is made of besides the calm ones (design-lively.ts). */
+1 -1
View File
@@ -56,7 +56,7 @@ describe('the art direction of design_book', () => {
for (const theme of BOOK_THEME_IDS) expect(bookDesignBriefSchema.safeParse({ theme }).success, theme).toBe(true);
});
it('DSNBRF-003: the cover takes an archetype of the seven and a lockup of the Text styles shelf', () => {
it('DSNBRF-003: the cover takes an archetype of the twelve and a lockup of the Text styles shelf', () => {
for (const archetype of BOOK_COVER_ARCHETYPES) expect(ok({ cover: { archetype } }), archetype).toBe(true);
for (const lockup of BOOK_LOCKUPS) expect(ok({ cover: { lockup: lockup.id } }), lockup.id).toBe(true);
expect(ok({ cover: { archetype: 'collage' } })).toBe(false);
@@ -0,0 +1,108 @@
import type { BookElement, BookFrame, BookPhotoElement, BookTextElement } from '../book.schema';
import { frameOf } from './design-compose';
import {
addLockup,
bandPhoto,
coverText,
deepened,
inkOn,
mmOn,
mmToPt,
shapeEl,
spacedLine,
standOut,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Editorial: the cover of a travel magazine. The title as a huge masthead
* across the top of the paper, a thin rule under it with the issue line (the
* month on the left, the place on the right), the cover photo edge to edge
* below (a field of colour without one), and the subtitle as the cover line
* on a block of the accent in a lower corner, off the picture's detail.
*/
const holds = (b: BookFrame, x: number, y: number) => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
/** A short line of spaced capitals set flush to one side. */
function issueLine(
k: CoverKit,
words: string,
frame: BookFrame,
color: string,
align: 'left' | 'right',
): BookTextElement {
return { ...spacedLine(k, words, frame, color, 8), align };
}
/** Where the cover line's block may lie: the lower left corner, else the lower right, clear of the photo's detail. */
function coverLineBox(k: CoverKit, print: BookPhotoElement | null, w: number, h: number): BookFrame | null {
const { W, H, m } = k;
const corners = [frameOf(m, H - m - h, w, h), frameOf(W - m - w, H - m - h, w, h)];
if (!print) return corners[0] ?? null;
const fx = print.frame.x + print.focalX * print.frame.w;
const fy = print.frame.y + print.focalY * print.frame.h;
return corners.find((b) => !holds(frameOf(b.x - 3, b.y - 3, b.w + 6, b.h + 6), fx, fy)) ?? null;
}
export function editorial(k: CoverKit): CoverDraft {
const { c, W, H, b, m } = k;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const ink = inkOn(c, ground);
const mast = H * k.rng.range(0.24, 0.28);
const zone = frameOf(m, m, W - m * 2, mast - m);
const ruleY = mast + mmOn(k, 1.5);
const lineY = ruleY + mmOn(k, 2);
const lineH = mmOn(k, 6);
const top = lineY + lineH + mmOn(k, 3);
const photo = k.photos[0];
const print = photo ? bandPhoto(k, photo, frameOf(-b, top, W + b * 2, H - top + b)) : null;
// Without a photo its place is a field of colour, as a magazine prints a cover of type alone.
draft.elements.push(print ?? shapeEl(k, 'rect', frameOf(-b, top, W + b * 2, H - top + b), standOut(c, ground)));
const head: BookElement[] = [];
const asked = addLockup(k, draft, { box: zone, ground });
if (!asked.length && k.title.words.trim()) {
head.push(
titleText(k, zone, {
max: mmToPt(zone.h * 0.82),
color: ink,
transform: 'upper',
leading: 0.9,
weight: 800,
}),
);
}
const thin = Math.max(0.3, mmOn(k, 0.4));
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W - m * 2, thin), ink));
const half = (W - m * 2) / 2 - mmOn(k, 2);
const left = k.words.dates || k.words.year || '';
const place = k.words.place ?? '';
if (left) head.push(issueLine(k, left, frameOf(m, lineY, half, lineH), ink, 'left'));
if (place && place !== left) head.push(issueLine(k, place, frameOf(W - m - half, lineY, half, lineH), ink, 'right'));
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
// The cover line: the subtitle on a block of the accent over the photo, or on the paper without one.
const sub = k.sub.words.trim();
if (!sub || asked.length) return draft;
const bw = W * k.rng.range(0.42, 0.5);
const bh = Math.min(mmOn(k, 30), (H - top) * 0.36);
const box = coverLineBox(k, print, bw, bh);
if (!box) return draft;
const block = deepened(print ? standOut(c, ground) : inkOn(c, ground));
const pad = mmOn(k, 4);
const words = coverText(k, sub, frameOf(box.x + pad, box.y + pad, box.w - pad * 2, box.h - pad * 2), {
slot: 'heading',
max: 16,
color: inkOn(c, block),
align: 'left',
leading: 1.1,
});
const line = k.sub.bound ? { ...words, binding: { source: 'journey.subtitle' as const } } : words;
const parts = [shapeEl(k, 'rect', box, block), line];
draft.elements.push(...parts);
draft.groups.push(parts.map((e) => e.id));
return draft;
}
@@ -0,0 +1,155 @@
import { deltaE, luminance, parseHex } from '../book-colour';
import { solveBookTextFit } from '../book-text-fit';
import type { BookElement, BookFrame, BookPhotoElement, BookShapeId, BookTextElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { CALM_ART } from './design-art';
import { frameOf } from './design-compose';
import {
accentOn,
addLockup,
clearOfTrim,
coverText,
inkOn,
keptTurn,
mmOn,
mmToPt,
printEl,
shapeEl,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
import { placeStickers, sharedArea, turnedBox } from './design-stickers';
/**
* Field notes: a page of a traveller's notebook. A sheet of squared paper
* laid a little askew on the kraft, the photos taped in as prints turned a
* few degrees each way with a strip of tape over their top edge, pressed
* leaves beside them, and under them the title in the display face, the
* subtitle (or the place) in the script, and the dates in mono.
*/
const WHITE = '#ffffff';
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
/** The sheet's paper: a light colour of the palette apart from the page, else the page lightened. */
function sheetOf(paper: string, accent2: string): string {
const tried = [accent2, mixHex(paper, WHITE, 0.6), '#f8f4ea'];
return tried.find((h) => lum(h) >= 0.72 && deltaE(h, paper) > 6) ?? '#f8f4ea';
}
/** Where the prints go, as shares of the page: a large one, one beside it and a small one under that. */
const SPOTS: readonly [number, number, number, number][] = [
[0.1, 0.1, 0.48, 0.4],
[0.62, 0.17, 0.28, 0.27],
[0.6, 0.5, 0.3, 0.22],
];
const LEAVES: readonly BookShapeId[] = ['leaf-long', 'leaf-oak', 'leaf'];
export function fieldNotes(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const p = c.look.theme.palette;
const draft: CoverDraft = { elements: [], groups: [] };
const sheet = sheetOf(c.look.paper, p.accent2);
const ink = inkOn(c, sheet);
const tilt = -k.rng.range(1, 2.5);
const inset = m * 0.55;
const sheetFrame = clearOfTrim(k, frameOf(inset, inset, W - inset * 2, H - inset * 2), tilt);
const lines = mixHex(ink, sheet, 0.86);
const scale = Math.round(k.rng.range(0.55, 0.7) * 100) / 100;
draft.elements.push({
...shapeEl(k, 'rect', sheetFrame, sheet, tilt),
paint: { type: 'pattern', pattern: 'grid', fg: lines, bg: sheet, scale, angle: 0 },
});
// The words take the foot of the sheet, left of the small print.
const top = H * 0.62;
const zone = frameOf(m, top, W * 0.54 - m, H - m - top);
const leaf = p.accent3;
const placed: BookFrame[] = [];
const prints: BookPhotoElement[] = [];
k.photos.slice(0, SPOTS.length).forEach((photo, i) => {
const [x, y, w, h] = SPOTS[i] as [number, number, number, number];
const turn = keptTurn(k, (i % 2 === 0 ? -1 : 1) * k.rng.range(2, 4));
const print = printEl(k, photo, frameOf(W * x, H * y, W * w, H * h), {
turn,
border: { width: mmOn(k, 2.6), color: WHITE },
shadow: true,
});
const box = turnedBox(print.frame, print.rotation);
if (placed.some((o) => sharedArea(o, box) > 0) || sharedArea(box, zone) > 0) return;
placed.push(box);
prints.push(print);
});
// Pressed leaves on the sheet, behind the prints and clear of the words.
const leaves: BookElement[] = [];
const spots: [number, number][] = [
[W * 0.07, H * 0.5],
[W * 0.9, H * 0.86],
];
k.rng
.shuffle(LEAVES)
.slice(0, spots.length)
.forEach((shape, i) => {
const [cx, cy] = spots[i] as [number, number];
const size = Math.min(W, H) * k.rng.range(0.16, 0.22);
const turn = k.rng.range(-40, 40);
const frame = clearOfTrim(k, frameOf(cx - size / 2, cy - size / 2, size, size), turn);
if (sharedArea(turnedBox(frame, turn), zone) > 0) return;
leaves.push({ ...shapeEl(k, shape, frame, leaf, turn), opacity: 0.9 });
});
draft.elements.push(...leaves);
// Each print with its strip of tape over the top edge, the tape see-through so the picture shows under it.
const tape = mixHex(sheet, c.look.accent, 0.22);
for (const print of prints) {
const tw = print.frame.w * 0.4;
const th = mmOn(k, 7);
const frame = frameOf(print.frame.x + (print.frame.w - tw) / 2, print.frame.y - th / 2, tw, th);
const strip = { ...shapeEl(k, 'rect', frame, tape, print.rotation + k.rng.range(-7, 7)), opacity: 0.72 };
const parts = [print, { ...strip, edge: 'zigzag' as const, edgeSize: mmOn(k, 1) }];
draft.elements.push(...parts);
draft.groups.push(parts.map((e) => e.id));
}
if (!addLockup(k, draft, { box: zone, ground: sheet }).length) notes(k, draft, zone, sheet, ink);
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(1, 3);
draft.elements.push(
...placeStickers(c, draft.elements, {
count,
size: [0.1, 0.15],
sides: ['single'],
key: 'cover:field-notes',
avoid: [zone],
used: c.art?.used,
}),
);
return draft;
}
/** The notebook's own words in a box: the title, a line in the script, the dates in mono at the foot. */
function notes(k: CoverKit, draft: CoverDraft, zone: BookFrame, sheet: string, ink: string): void {
const words: BookTextElement[] = [];
const script = k.sub.words.trim() || k.words.place || '';
const dates = k.words.dates || k.words.year || '';
const scriptH = script ? Math.min(mmOn(k, 14), zone.h * 0.26) : 0;
const datesH = dates ? mmOn(k, 6) : 0;
const titleH = zone.h - scriptH - datesH - mmOn(k, 2);
if (k.title.words.trim()) {
const frame = frameOf(zone.x, zone.y, zone.w, titleH);
words.push(titleText(k, frame, { max: mmToPt(titleH * 0.6), color: ink, align: 'left', leading: 0.95 }));
}
if (script) {
const frame = frameOf(zone.x, zone.y + titleH, zone.w, scriptH);
const max = k.c.look.theme.fonts.script ? 26 : 14;
const line = coverText(k, script, frame, { slot: 'script', max, color: accentOn(k.c, sheet), align: 'left' });
const bound = script === k.sub.words.trim() && k.sub.bound;
words.push(bound ? { ...line, binding: { source: 'journey.subtitle' } } : line);
}
if (dates) {
const frame = frameOf(zone.x, zone.y + zone.h - datesH, zone.w, datesH);
const line = coverText(k, dates, frame, { slot: 'heading', max: 8, color: ink, align: 'left', tracking: 0.12 });
const mono: BookTextElement = { ...line, font: 'mono', weight: 400, fitMax: 8 };
words.push({ ...mono, size: solveBookTextFit(mono, 8) });
}
draft.elements.push(...words);
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
}
+26 -1
View File
@@ -15,7 +15,7 @@ import type {
import { bookThemeFont, isBookFontFamily, mixHex, mutedOn } from '../theme/book-theme';
import type { BookFontSlot, BookThemeFont } from '../theme/book-theme.schema';
import { CALM_ART, withinTilt } from './design-art';
import { bleedPage, frameOf, photoEl, type ComposeCtx } from './design-compose';
import { bleedPage, frameOf, photoEl, printsAt, type ComposeCtx } from './design-compose';
import { titleWords, type CoverWords } from './design-cover-words';
import { designRng, type DesignRng } from './design-random';
import { turnedBox } from './design-stickers';
@@ -171,6 +171,12 @@ export function standOut(c: ComposeCtx, ground: string, skip: readonly string[]
return tried.find((h) => deltaE(h, ground) > 18 && contrastRatio(h, ground) >= 1.4) ?? inkOn(c, ground);
}
/** An accent small words read in on a ground (4.5:1), else the ink that reads there. */
export function accentOn(c: ComposeCtx, ground: string): string {
const p = c.look.theme.palette;
return [c.look.accent, p.accent3, p.accent2].find((h) => contrastRatio(h, ground) >= 4.5) ?? inkOn(c, ground);
}
/**
* A lockup's tones on a ground: the ground as its paper, an ink and a muted
* ink that read on it, and an accent that reads on it at 4.5:1, so the words
@@ -397,6 +403,12 @@ export function spacedLine(k: PageKit, words: string, frame: BookFrame, color: s
});
}
/** A text on a band of a colour behind its words, set in the ink that reads on the band. */
export function onBand(k: PageKit, el: BookTextElement, band: string): BookTextElement {
const highlight = { color: band, alpha: 1, padX: mmOn(k, 1.6), padY: mmOn(k, 0.6), radius: 0 };
return { ...el, color: inkOn(k.c, band), highlight };
}
// ── shapes and photos ──────────────────────────────────────────────────────
/** A filled shape, unturned unless said, with no outline. */
@@ -456,6 +468,19 @@ export function bleedPhoto(k: CoverKit, photo: AutoPhoto): BookPhotoElement {
return photoEl(k.c, photo, bleedPage(k.c, 'single'), { bleed: { side: 'single' } });
}
/**
* A photo across the page in a band of it, to the bleed at both sides (a
* magazine's picture under its masthead, a letterboxed still): to the bleed
* when it prints sharp at that size, else as a print inside the margins over
* the same rows, as the cover photo falls back.
*/
export function bandPhoto(k: CoverKit, photo: AutoPhoto, band: BookFrame): BookPhotoElement {
if (printsAt(k.c, photo, band)) return photoEl(k.c, photo, band, { bleed: { side: 'single' } });
const y = Math.max(band.y, k.m);
const bottom = Math.min(band.y + band.h, k.H - k.m);
return printEl(k, photo, frameOf(k.m, y, k.W - k.m * 2, bottom - y));
}
interface PrintLook {
turn?: number;
border?: { width: number; color: string };
@@ -0,0 +1,142 @@
import { luminance, parseHex } from '../book-colour';
import { solveBookTextFit } from '../book-text-fit';
import type { BookElement, BookFrame, BookLineElement, BookTextElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { frameOf } from './design-compose';
import {
accentOn,
addLockup,
bandPhoto,
coverText,
inkOn,
mmOn,
mmToPt,
printEl,
shapeEl,
spacedLine,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Night film: a dark cinematic page. The month and the place in mono in the
* neon accent above a letterboxed still to the bleed, the title in tall
* condensed capitals widely spaced with a short neon rule, and across the
* foot a strip of film, its sprocket holes two dashed lines, holding the
* next photos as frames.
*/
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
/** The page's near black: the darker of the paper and the ink, taken towards black until it is one. */
function nightOf(paper: string, ink: string): string {
let night = lum(paper) <= lum(ink) ? paper : ink;
for (let step = 0; step < 8 && lum(night) > 0.012; step++) night = mixHex(night, '#000000', 0.35);
return night;
}
/** A straight dashed line across a frame, as a row of sprocket holes. */
function sprockets(k: CoverKit, frame: BookFrame, stroke: string): BookLineElement {
return {
id: k.c.newId('l'),
kind: 'line',
frame,
rotation: 0,
opacity: 1,
locked: false,
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke,
strokeWidth: Math.round(frame.h * 100) / 100,
dash: 'dashed',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
};
}
/** A line in mono, as a timecode is printed on the edge of a film. */
function monoLine(
k: CoverKit,
words: string,
frame: BookFrame,
color: string,
align: 'left' | 'right',
): BookTextElement {
const text = coverText(k, words, frame, { slot: 'heading', max: 8, color, align, tracking: 0.2 });
const mono: BookTextElement = { ...text, font: 'mono', weight: 500, transform: 'upper', fitMax: 8 };
return { ...mono, size: solveBookTextFit(mono, 8) };
}
export function nightFilm(k: CoverKit): CoverDraft {
const { c, W, H, b, m } = k;
const night = nightOf(c.look.paper, c.look.ink);
const draft: CoverDraft = { elements: [], groups: [], ...(night === c.look.paper ? {} : { background: night }) };
const light = inkOn(c, night);
const neon = accentOn(c, night);
// Down the page: the timecode line, the still, the title, the strip.
const metaH = mmOn(k, 6);
const stillY = m + metaH + mmOn(k, 4);
const stillH = Math.min(H * k.rng.range(0.36, 0.42), W * 0.46);
const rest = H - m - (stillY + stillH) - mmOn(k, 12);
const stripH = Math.min(rest * 0.5, W * 0.24);
const stripY = H - m - stripH;
const titleY = stillY + stillH + mmOn(k, 6);
const titleBox = frameOf(m, titleY, W - m * 2, stripY - mmOn(k, 6) - titleY);
const [photo, ...more] = k.photos;
if (photo) draft.elements.push(bandPhoto(k, photo, frameOf(-b, stillY, W + b * 2, stillH)));
// The film: a band a shade off the night, sprockets along both edges, the frames between them.
const film = mixHex(night, light, 0.1);
const hole = Math.max(0.6, stripH * 0.09);
draft.elements.push(
shapeEl(k, 'rect', frameOf(-b, stripY, W + b * 2, stripH), film),
sprockets(k, frameOf(-b, stripY + stripH * 0.12 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
sprockets(k, frameOf(-b, stripY + stripH * 0.88 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
);
const frames = more.slice(0, 3);
const gap = mmOn(k, 3);
const fw = frames.length ? (W - m * 2 - gap * (frames.length - 1)) / frames.length : 0;
frames.forEach((p, i) => {
draft.elements.push(printEl(k, p, frameOf(m + i * (fw + gap), stripY + stripH * 0.22, fw, stripH * 0.56)));
});
const meta: BookElement[] = [];
const when = k.words.dates || k.words.year || '';
const place = k.words.place ?? '';
const half = (W - m * 2) / 2 - mmOn(k, 2);
if (when) meta.push(monoLine(k, when, frameOf(m, m, half, metaH), neon, 'left'));
if (place && place !== when) meta.push(monoLine(k, place, frameOf(W - m - half, m, half, metaH), neon, 'right'));
draft.elements.push(...meta);
if (meta.length > 1) draft.groups.push(meta.map((e) => e.id));
if (addLockup(k, draft, { box: titleBox, ground: night }).length) return draft;
const head: BookElement[] = [];
const sub = k.sub.words.trim();
const subH = sub ? mmOn(k, 7) : 0;
if (k.title.words.trim()) {
const box = frameOf(titleBox.x, titleBox.y, titleBox.w, titleBox.h - subH - mmOn(k, 3));
head.push(
titleText(k, box, {
max: mmToPt(box.h * 0.85),
color: light,
transform: 'upper',
tracking: 0.14,
leading: 0.9,
align: 'left',
}),
);
}
const ruleY = titleBox.y + titleBox.h - subH - mmOn(k, 1.5);
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W * 0.16, Math.max(0.6, mmOn(k, 0.8))), neon));
if (sub) {
const line = spacedLine(k, sub, frameOf(m, titleBox.y + titleBox.h - subH, W * 0.6, subH), neon, 9);
head.push({ ...line, align: 'left', ...(k.sub.bound ? { binding: { source: 'journey.subtitle' as const } } : {}) });
}
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
return draft;
}
+119
View File
@@ -0,0 +1,119 @@
import { luminance, parseHex } from '../book-colour';
import type { BookElement, BookFrame } from '../book.schema';
import { CALM_ART } from './design-art';
import { frameOf } from './design-compose';
import {
addLockup,
clearOfTrim,
deepened,
inkOn,
keptTurn,
mmOn,
mmToPt,
onBand,
printEl,
shapeEl,
spacedLine,
standOut,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
import { placeStickers, turnedBox } from './design-stickers';
/**
* Riso print: a page printed in two or three spot inks, each a little out of
* register with the next. A disc of one ink and a halftone disc of another
* laid over it off centre, the cover photo as a duotone of the book's ink on
* a block of the first ink that shows past its edge, the title in heavy
* capitals with a hard shadow of that ink beside it, the subtitle and the
* year on bands of their own, and a few doodles.
*/
/** The darker and the lighter of two colours, for a duotone that keeps its tones the right way round in any theme. */
function tonesOf(a: string, b: string): { dark: string; light: string } {
const lum = (h: string) => luminance(parseHex(h) ?? { r: 255, g: 255, b: 255 });
return lum(a) <= lum(b) ? { dark: a, light: b } : { dark: b, light: a };
}
/** A box moved down until it starts below `top`, or left where it is. */
const below = (box: BookFrame, top: number): BookFrame => (box.y >= top ? box : { ...box, y: top });
export function riso(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const first = standOut(c, ground);
const second = standOut(c, ground, [first]);
const titleZone = frameOf(m, m, W - m * 2, H * 0.3);
const titleFoot = titleZone.y + titleZone.h + mmOn(k, 12);
const photo = k.photos[0];
const pw = W * k.rng.range(0.5, 0.56);
const ph = Math.min(H * 0.46, H - m - titleFoot);
const at = frameOf(W - m - pw - k.rng.range(0, 0.04) * W, H - m - ph, pw, ph);
// The two inks of the discs, the second laid over the first a few millimetres off.
const d = Math.min(W, H) * k.rng.range(0.5, 0.6);
const miss = mmOn(k, k.rng.range(3, 5));
const disc = clearOfTrim(k, below(frameOf(at.x - d * 0.45, at.y + at.h * 0.35 - d / 2, d, d), titleFoot));
const solid = shapeEl(k, 'ellipse', disc, second);
const dots = shapeEl(k, 'ellipse', clearOfTrim(k, frameOf(disc.x + miss, disc.y - miss, d, d)), first);
const scale = Math.round(k.rng.range(0.35, 0.5) * 100) / 100;
draft.elements.push(
{ ...solid, opacity: 0.85 },
{ ...dots, paint: { type: 'pattern', pattern: 'dots', fg: first, bg: null, scale, angle: 45 } },
);
let photoBox: BookFrame | null = null;
if (photo) {
const turn = keptTurn(k, k.rng.sign() * k.rng.range(1.5, 3));
const print = printEl(k, photo, at, { turn });
const shift = mmOn(k, k.rng.range(2.5, 4));
const offset = frameOf(print.frame.x - shift, print.frame.y + shift, print.frame.w, print.frame.h);
const block = shapeEl(k, 'rect', offset, first, print.rotation);
draft.elements.push(block, { ...print, duotone: tonesOf(c.look.ink, c.look.paper) });
photoBox = turnedBox(offset, print.rotation);
}
const ink = inkOn(c, ground);
const head: BookElement[] = [];
if (!addLockup(k, draft, { box: titleZone, ground }).length) {
if (k.title.words.trim()) {
const title = titleText(k, titleZone, {
max: mmToPt(titleZone.h / 2 / 0.95),
color: ink,
transform: 'upper',
leading: 0.92,
weight: 800,
align: 'left',
});
const dx = mmOn(k, 1.6);
head.push({ ...title, shadow: { dx, dy: dx, color: first, alpha: 1 } });
}
const sub = k.sub.words.trim();
if (sub) {
const line = spacedLine(k, sub, frameOf(m, titleZone.y + titleZone.h + mmOn(k, 3), W * 0.5, mmOn(k, 7)), ink, 10);
const bound = k.sub.bound ? { ...line, binding: { source: 'journey.subtitle' as const } } : line;
head.push(onBand(k, { ...bound, align: 'left' }, ground));
}
draft.elements.push(...head);
if (head.length > 1) draft.groups.push(head.map((t) => t.id));
}
const year = k.words.year ?? '';
// The year on a band of the second ink at the foot, left of the photo and its block, deepened
// where a mid-tone ink would not carry a small line.
const room = (photoBox ? photoBox.x : W - m) - m - mmOn(k, 4);
if (year && room > mmOn(k, 18)) {
const frame = frameOf(m, H - m - mmOn(k, 7), Math.min(room, mmOn(k, 40)), mmOn(k, 7));
draft.elements.push(onBand(k, { ...spacedLine(k, year, frame, ink, 9), align: 'left' }, deepened(second)));
}
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(2, 4);
draft.elements.push(
...placeStickers(c, draft.elements, {
count,
size: [0.1, 0.16],
sides: ['single'],
key: 'cover:riso',
avoid: [titleZone],
used: c.art?.used,
}),
);
return draft;
}
@@ -0,0 +1,96 @@
import { deltaE } from '../book-colour';
import type { BookElement } from '../book.schema';
import { mixHex } from '../theme/book-theme';
import { frameOf } from './design-compose';
import {
addLockup,
inkOn,
mmOn,
mmToPt,
printEl,
shapeEl,
spacedLine,
titleText,
type CoverDraft,
type CoverKit,
} from './design-cover-kit';
/**
* Vintage poster: a travel poster of the 1950s. A thin rule framing the
* page, and inside it a scenic panel of flat colour: a sky, a sun with its
* rays at the horizon, two ranges of mountains and a band of sea, the cover
* photo standing in an arch before the sun with a white cut edge, and under
* the panel the title stacked in the display face with the month at the foot.
*/
const WHITE = '#ffffff';
/** A colour of the palette that stands apart from another (delta E over 18), else the fallback. */
const apart = (want: string, from: string, fallback: string) => (deltaE(want, from) > 18 ? want : fallback);
export function vintagePoster(k: CoverKit): CoverDraft {
const { c, W, H, m } = k;
const p = c.look.theme.palette;
const draft: CoverDraft = { elements: [], groups: [] };
const ground = c.look.paper;
const ink = inkOn(c, ground);
const sun = c.look.accent;
const sky = apart(mixHex(p.accent3, ground, 0.3), sun, mixHex(p.accent2, ground, 0.45));
const near = apart(p.accent2, sky, mixHex(ink, sky, 0.35));
const far = mixHex(near, sky, 0.45);
const sea = mixHex(near, ink, 0.25);
const hair = Math.max(0.3, mmOn(k, 0.6));
const edge = mmOn(k, 3.5);
const border = { ...shapeEl(k, 'rect', frameOf(edge, edge, W - edge * 2, H - edge * 2), ink), fill: null };
draft.elements.push({ ...border, stroke: ink, strokeWidth: hair });
const inset = mmOn(k, 7);
const panel = frameOf(inset, inset, W - inset * 2, H * k.rng.range(0.6, 0.64) - inset);
const horizon = panel.y + panel.h * k.rng.range(0.5, 0.56);
const rays = Math.min(panel.w, panel.h) * 0.92;
const ry = Math.min(Math.max(horizon - rays / 2, panel.y), panel.y + panel.h - rays);
const disc = rays * 0.6;
draft.elements.push(
shapeEl(k, 'rect', panel, sky),
{ ...shapeEl(k, 'sunburst-24', frameOf(W / 2 - rays / 2, ry, rays, rays), mixHex(sun, sky, 0.5)), opacity: 0.7 },
shapeEl(k, 'ellipse', frameOf(W / 2 - disc / 2, ry + (rays - disc) / 2, disc, disc), sun),
);
// The ranges from the horizon down to the panel's foot, the far one paler, the sea in front of them.
const foot = panel.y + panel.h;
const farW = panel.w * k.rng.range(0.6, 0.7);
const nearW = panel.w * k.rng.range(0.62, 0.72);
const left = k.rng.chance(0.5);
const seaH = panel.h * 0.12;
const farX = left ? panel.x : panel.x + panel.w - farW;
const nearX = left ? panel.x + panel.w - nearW : panel.x;
const nearY = horizon + (foot - horizon) * 0.25;
draft.elements.push(
shapeEl(k, 'mountain', frameOf(farX, horizon, farW, foot - horizon), far),
shapeEl(k, 'mountain', frameOf(nearX, nearY, nearW, foot - nearY), near),
shapeEl(k, 'rect', frameOf(panel.x, foot - seaH, panel.w, seaH), sea),
);
const photo = k.photos[0];
if (photo) {
const ph = panel.h * 0.74;
const pw = Math.min(ph * 0.58, panel.w * 0.42);
const arch = printEl(k, photo, frameOf(W / 2 - pw / 2, foot - ph - mmOn(k, 3), pw, ph), {
mask: 'arch-tall',
cutout: { width: mmOn(k, 2), color: WHITE },
});
draft.elements.push(arch);
}
// Under the panel: the title stacked in the display face, the month or the year at the foot.
const line = k.words.dates || k.words.year || '';
const lineH = line ? mmOn(k, 7) : 0;
const top = foot + mmOn(k, 5);
const box = frameOf(m, top, W - m * 2, H - m - top - lineH - mmOn(k, 2));
const words: BookElement[] = [];
if (!addLockup(k, draft, { box: frameOf(box.x, box.y, box.w, H - m - top), ground }).length) {
if (k.title.words.trim()) {
words.push(titleText(k, box, { max: mmToPt(box.h * 0.8), color: ink, transform: 'upper', leading: 0.92 }));
}
if (line) words.push(spacedLine(k, line, frameOf(m, H - m - lineH, W - m * 2, lineH), ink, 9));
draft.elements.push(...words);
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
}
return draft;
}
+34 -1
View File
@@ -1,4 +1,5 @@
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
import { luminance, parseHex } from '../book-colour';
import { bookSpreadSchema, type BookElement, type BookSpread } from '../book.schema';
import { pageFindings } from '../describe-spread';
import { BOOK_STICKERS } from '../stickers/sticker-catalogue';
@@ -11,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* The seven composed covers (design-cover.ts) on the fixture journeys: each
* The twelve composed covers (design-cover.ts) on the fixture journeys: each
* a valid page, its words inside the page, nothing left for the repair to
* flag, its archetype's signature there, the same for one seed and another
* for another seed, and the cover of a design without an archetype exactly
@@ -73,6 +74,38 @@ const SIGNATURE: Record<BookCoverArchetype, (s: BookSpread, W: number, H: number
photos(s).some((p) => p.mask === 'arch-tall') &&
photos(s).some((p) => p.edge === 'perforated') &&
shapes(s).some((e) => e.shape === 'ellipse' && e.fill === null),
riso: (s) =>
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'dots') &&
texts(s).some((t) => t.binding?.source === 'journey.title' && !!t.shadow) &&
photos(s).some(
(p) =>
!!p.duotone &&
shapes(s).some(
(e) =>
e.shape === 'rect' &&
Math.abs(e.frame.w - p.frame.w) < 0.5 &&
Math.abs(e.frame.h - p.frame.h) < 0.5 &&
(e.frame.x !== p.frame.x || e.frame.y !== p.frame.y),
),
),
editorial: (s, W, H) =>
shapes(s).some((e) => e.shape === 'rect' && e.frame.h <= 1 && e.frame.w >= W * 0.6) &&
texts(s).some((t) => t.binding?.source === 'journey.title' && t.frame.y + t.frame.h <= H * 0.32) &&
photos(s).some((p) => p.frame.w >= W * 0.6),
'vintage-poster': (s, W) =>
shapes(s).filter((e) => e.shape === 'mountain').length >= 2 &&
shapes(s).some((e) => e.shape === 'sunburst-24') &&
shapes(s).some((e) => e.fill === null && !!e.stroke && e.frame.w >= W * 0.8) &&
photos(s).every((p) => p.mask === 'arch-tall'),
'night-film': (s, W) =>
luminance(parseHex(s.background ?? '#ffffff') ?? { r: 255, g: 255, b: 255 }) < 0.05 &&
kinds(s, 'line').filter((e) => e.kind === 'line' && e.dash === 'dashed').length >= 2 &&
shapes(s).some((e) => e.shape === 'rect' && e.frame.w >= W),
'field-notes': (s) =>
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'grid') &&
shapes(s).some((e) => e.shape.startsWith('leaf')) &&
photos(s).length > 0 &&
photos(s).every((p) => !!p.border),
};
/** Every word on a cover lies on the page, turned with its frame; every other element at least touches it. */
+11 -1
View File
@@ -4,6 +4,8 @@ import { solveBookTextFit } from '../book-text-fit';
import type { BookElement, BookSpread } from '../book.schema';
import { frameOf, scrimEl, type ComposeCtx } from './design-compose';
import { gridBadge, stampCollage } from './design-cover-collage';
import { editorial } from './design-cover-editorial';
import { fieldNotes } from './design-cover-field-notes';
import { postcard, posterPop } from './design-cover-graphic';
import {
addLockup,
@@ -13,12 +15,15 @@ import {
type CoverKit,
type CoverMaterial,
} from './design-cover-kit';
import { nightFilm } from './design-cover-night-film';
import { cutout, minimalSerif, spineBand } from './design-cover-photo';
import { riso } from './design-cover-riso';
import { vintagePoster } from './design-cover-vintage-poster';
import { titleWords, type CoverWords } from './design-cover-words';
import { paperBackground } from './design-style';
/**
* The composed covers: `cover.archetype` picks one of seven composers, each
* The composed covers: `cover.archetype` picks one of twelve composers, each
* building its archetype from the book's own material (its photos, its words,
* its theme's colours, faces and motifs, the lockups) with the seed's
* variation, deterministic for one seed. A cover without an archetype is the
@@ -33,6 +38,11 @@ const COMPOSERS: Readonly<Record<BookCoverArchetype, (k: CoverKit) => CoverDraft
'grid-badge': gridBadge,
'minimal-serif': minimalSerif,
'stamp-collage': stampCollage,
riso,
editorial,
'vintage-poster': vintagePoster,
'night-film': nightFilm,
'field-notes': fieldNotes,
};
/** A draft as the cover page: on its ground, each lockup one group. */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,844 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More grid and badge pages: a cover of eight tiles round a red centre
* badge, a words only spread with a large seal and the story, and a spread
* of a hero photo with a scalloped badge beside six tall tiles. Drawn in
* grid-badge.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_GRID_BADGE: readonly BookDecoratedTemplate[] = [
{
id: 'grid-badge-tiles',
labelKey: 'studio.templates.name.gridBadgeTiles',
kind: 'single',
archetype: 'grid-badge',
tags: ['grid', 'eight-photos', 'badge', 'tiles', 'red', 'postmark', 'graphic'],
descr:
'Eight bleed photos three by three with 4 mm white gutters round a red centre tile holding a white hairline ring, a script line, the title in heavy capitals, a short rule and the year, an ink postmark on its corner.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 'p1',
role: 'photo:1',
frame: { x: -0.0143, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p2',
role: 'photo:2',
frame: { x: 0.3349, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p3',
role: 'photo:3',
frame: { x: 0.6841, y: -0.0143, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p4',
role: 'photo:4',
frame: { x: -0.0143, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p5',
role: 'photo:5',
frame: { x: 0.6841, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p6',
role: 'photo:6',
frame: { x: -0.0143, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p7',
role: 'photo:7',
frame: { x: 0.3349, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p8',
role: 'photo:8',
frame: { x: 0.6841, y: 0.6841, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 's9',
frame: { x: 0.3349, y: 0.3349, w: 0.3302, h: 0.3302 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's10',
keep: true,
frame: { x: 0.3619, y: 0.3619, w: 0.2762, h: 0.2762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#ffffff',
strokeToken: 'paper',
strokeWidth: 0.0019,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't11',
role: 'subtitle',
keep: true,
on: 'accent',
frame: { x: 0.4048, y: 0.3952, w: 0.1905, h: 0.0476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'allura',
size: 0.0667,
weight: 400,
italic: false,
align: 'center',
leading: 1,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'bottom',
fit: 'shrink',
fitMax: 0.0667,
},
{
id: 't12',
role: 'title',
keep: true,
on: 'accent',
frame: { x: 0.3857, y: 0.4476, w: 0.2286, h: 0.0857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0952,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0952,
},
{
id: 's13',
keep: true,
frame: { x: 0.4762, y: 0.5429, w: 0.0476, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't14',
role: 'date',
keep: true,
on: 'accent',
frame: { x: 0.4048, y: 0.5571, w: 0.1905, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0333,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0333,
},
{
id: 's15',
tint: { colors: ['ink'] },
frame: { x: 0.6, y: 0.6476, w: 0.1714, h: 0.0997 },
rotation: -12,
opacity: 0.85,
locked: false,
kind: 'sticker',
stickerId: 'postmark',
colors: ['#141414'],
flipX: false,
},
],
},
{
id: 'grid-badge-letter',
labelKey: 'studio.templates.name.gridBadgeLetter',
kind: 'inner',
archetype: 'grid-badge',
tags: ['words-only', 'story', 'seal', 'badge', 'squared-paper', 'airmail', 'calm'],
descr:
'The left page cream squared paper with a large red seal holding the subtitle round its rim, the title in heavy capitals and the date; the right page a dashed airmail line to a small plane, the long story and a passport stamp.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { fg: 'accent3', bg: 'accent2' },
frame: { x: -0.0143, y: -0.0143, w: 1.0143, h: 1.0286 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#6f6f6f',
gradient: 'none',
paint: { type: 'pattern', pattern: 'grid', fg: '#6f6f6f', bg: '#f2eee7', scale: 1.2, angle: 0 },
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
keep: true,
frame: { x: 0.1857, y: 0.1457, w: 0.6286, h: 0.6286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'seal',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's3',
keep: true,
frame: { x: 0.2429, y: 0.2029, w: 0.5143, h: 0.5143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#ffffff',
strokeToken: 'paper',
strokeWidth: 0.0019,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't4',
role: 'subtitle',
keep: true,
on: 'accent',
frame: { x: 0.2619, y: 0.2219, w: 0.4762, h: 0.4762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0429,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.3,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
curve: { type: 'circle', bend: 40, start: 0 },
fit: 'shrink',
fitMax: 0.0429,
},
{
id: 't5',
role: 'title',
keep: true,
on: 'accent',
frame: { x: 0.3095, y: 0.3905, w: 0.381, h: 0.1238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.1524,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#ffffff',
token: 'paper',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1524,
},
{
id: 's6',
keep: true,
frame: { x: 0.4619, y: 0.5238, w: 0.0762, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't7',
role: 'date',
keep: true,
on: 'accent',
frame: { x: 0.3333, y: 0.5381, w: 0.3333, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#ffffff',
token: 'paper',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 'l8',
tint: { stroke: 'ink' },
frame: { x: 1.0762, y: 0.0833, w: 0.6762, h: 0.0143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#141414',
strokeWidth: 0.0029,
dash: 'dashed',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
},
{
id: 's9',
tint: { colors: ['accent3', 'accent', 'paper'] },
frame: { x: 1.7714, y: 0.0476, w: 0.1524, h: 0.0912 },
rotation: -8,
opacity: 1,
locked: false,
group: 'head',
kind: 'sticker',
stickerId: 'airplane',
colors: ['#6f6f6f', '#e2412f', '#ffffff'],
flipX: false,
},
{
id: 't10',
role: 'body',
frame: { x: 1.0762, y: 0.1619, w: 0.6667, h: 0.7333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#141414',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 's11',
tint: { colors: ['accent'] },
frame: { x: 1.7524, y: 0.7333, w: 0.1905, h: 0.1333 },
rotation: 14,
opacity: 0.9,
locked: false,
kind: 'sticker',
stickerId: 'passportStamp',
colors: ['#e2412f'],
flipX: false,
},
],
},
{
id: 'grid-badge-hero-six',
labelKey: 'studio.templates.name.gridBadgeHeroSix',
kind: 'inner',
archetype: 'grid-badge',
tags: ['grid', 'seven-photos', 'hero', 'badge', 'scalloped', 'full-bleed', 'graphic'],
descr:
'A bleed hero photo on the left page with a white scalloped badge low on it, a dashed red ring, a red pin, the subtitle, the title and the date; six tall photos three by two on the right page with 3 mm white gutters.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 'p1',
role: 'photo:1',
frame: { x: -0.0143, y: -0.0143, w: 1.0072, h: 1.0286 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.6,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p2',
role: 'photo:2',
frame: { x: 1.0071, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p3',
role: 'photo:3',
frame: { x: 1.3476, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p4',
role: 'photo:4',
frame: { x: 1.6881, y: -0.0143, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p5',
role: 'photo:5',
frame: { x: 1.0071, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p6',
role: 'photo:6',
frame: { x: 1.3476, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 'p7',
role: 'photo:7',
frame: { x: 1.6881, y: 0.5071, w: 0.3262, h: 0.5071 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
},
{
id: 's8',
keep: true,
frame: { x: 0.0857, y: 0.5333, w: 0.381, h: 0.381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'scallop-circle',
fill: '#ffffff',
gradient: 'none',
stroke: null,
token: 'paper',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's9',
keep: true,
frame: { x: 0.1048, y: 0.5524, w: 0.3429, h: 0.3429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#e2412f',
strokeToken: 'accent',
strokeWidth: 0.0029,
strokeStyle: 'dashed',
radius: 0,
},
{
id: 's10',
tint: { colors: ['accent', 'paper'] },
frame: { x: 0.2381, y: 0.4952, w: 0.0762, h: 0.1088 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'sticker',
stickerId: 'mapPin',
colors: ['#e2412f', '#ffffff'],
flipX: false,
},
{
id: 't11',
role: 'subtitle',
keep: true,
on: 'paper',
frame: { x: 0.1381, y: 0.6333, w: 0.2762, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.031,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.32,
color: '#5b5b5b',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.031,
},
{
id: 't12',
role: 'title',
keep: true,
on: 'paper',
frame: { x: 0.1238, y: 0.6762, w: 0.3048, h: 0.1048 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.1238,
weight: 800,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#141414',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1238,
},
{
id: 's13',
keep: true,
frame: { x: 0.2381, y: 0.7905, w: 0.0762, h: 0.0038 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'shape',
shape: 'rect',
fill: '#e2412f',
gradient: 'none',
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't14',
role: 'date',
keep: true,
on: 'paper',
frame: { x: 0.1714, y: 0.8048, w: 0.2095, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'badge',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0333,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.4,
color: '#141414',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0333,
},
],
},
];
@@ -0,0 +1,637 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More minimal serif pages: an oval cameo cover inside a hairline frame, a
* words only essay spread and a triptych of three numbered portrait plates.
* Drawn in minimal-serif.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_MINIMAL_SERIF: readonly BookDecoratedTemplate[] = [
{
id: 'minimal-serif-cameo',
labelKey: 'studio.templates.name.minimalSerifCameo',
kind: 'single',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'white', 'oval', 'cameo', 'hairline', 'elegant'],
descr:
'A white page inside a hairline frame: the year in spaced capitals at the head, an oval photo in a fine accent ring, a large thin italic serif title, a diamond rule and the subtitle.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: null,
gradient: 'none',
stroke: '#222222',
strokeToken: 'ink',
strokeWidth: 0.0012,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't2',
role: 'date',
frame: { x: 0.3333, y: 0.0857, w: 0.3333, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.45,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 's3',
keep: true,
frame: { x: 0.2952, y: 0.1429, w: 0.4095, h: 0.4952 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'shape',
shape: 'ellipse',
fill: null,
gradient: 'none',
stroke: '#857d72',
strokeToken: 'accent',
strokeWidth: 0.0012,
strokeStyle: 'solid',
radius: 0,
},
{
id: 'p4',
role: 'photo:1',
keep: true,
tint: { border: 'ink' },
frame: { x: 0.3143, y: 0.1667, w: 0.3714, h: 0.4476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.45,
radius: 0,
filter: 'none',
mask: 'ellipse',
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't5',
role: 'title',
frame: { x: 0.0952, y: 0.6619, w: 0.8095, h: 0.1476 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.3333,
weight: 300,
italic: true,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.3333,
},
{
id: 'l6',
tint: { stroke: 'accent' },
frame: { x: 0.4048, y: 0.819, w: 0.1905, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'diamond',
},
{
id: 't7',
role: 'subtitle',
frame: { x: 0.1429, y: 0.8571, w: 0.7143, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
],
},
{
id: 'minimal-serif-essay',
labelKey: 'studio.templates.name.minimalSerifEssay',
kind: 'inner',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'words-only', 'essay', 'story', 'ornament', 'calm'],
descr:
'Words only: on the left page a fleuron, the date in spaced capitals, a large thin serif title, a diamond rule and the subtitle, centred; on the right a hairline, the long story in one column and a star rule.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { colors: ['accent'] },
frame: { x: 0.4286, y: 0.1714, w: 0.1429, h: 0.1429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'sticker',
stickerId: 'fleuron',
colors: ['#857d72'],
flipX: false,
},
{
id: 't2',
role: 'date',
frame: { x: 0.1429, y: 0.3524, w: 0.7143, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.42,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't3',
role: 'title',
frame: { x: 0.0762, y: 0.4048, w: 0.8476, h: 0.2667 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.2857,
weight: 300,
italic: false,
align: 'center',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.2857,
},
{
id: 'l4',
tint: { stroke: 'accent' },
frame: { x: 0.381, y: 0.6952, w: 0.2381, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'diamond',
},
{
id: 't5',
role: 'subtitle',
frame: { x: 0.1429, y: 0.7429, w: 0.7143, h: 0.0667 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'center',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'top',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 'l6',
tint: { stroke: 'ink' },
frame: { x: 1.1429, y: 0.0857, w: 0.7143, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#222222',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'none',
},
{
id: 't7',
role: 'body',
frame: { x: 1.1429, y: 0.1524, w: 0.7143, h: 0.7143 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.6,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 's8',
tint: { colors: ['accent'] },
frame: { x: 1.381, y: 0.9, w: 0.2381, h: 0.0408 },
rotation: 0,
opacity: 1,
locked: false,
group: 'column',
kind: 'sticker',
stickerId: 'starRule',
colors: ['#857d72'],
flipX: false,
},
],
},
{
id: 'minimal-serif-triptych',
labelKey: 'studio.templates.name.minimalSerifTriptych',
kind: 'inner',
archetype: 'minimal-serif',
tags: ['minimal', 'serif', 'triptych', 'three-photos', 'portrait', 'hairline', 'calm'],
descr:
'A thin serif title, a sun rule, the subtitle, the date and the story in a narrow left column; three tall photos with hairline borders on one line across the spread, each numbered by one, two or three accent dots.',
background: null,
backgroundToken: 'paper',
elements: [
{
id: 't1',
role: 'title',
frame: { x: 0.0762, y: 0.1619, w: 0.4381, h: 0.2857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'cormorant',
size: 0.1905,
weight: 300,
italic: false,
align: 'left',
leading: 0.95,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'bottom',
fit: 'shrink',
fitMax: 0.1905,
},
{
id: 'l2',
tint: { stroke: 'accent' },
frame: { x: 0.0762, y: 0.4667, w: 0.1905, h: 0.0286 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'line',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
],
curve: 'straight',
stroke: '#857d72',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'sun',
},
{
id: 't3',
role: 'subtitle',
frame: { x: 0.0762, y: 0.5143, w: 0.4381, h: 0.0619 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.32,
color: '#646464',
token: 'muted',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'top',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't4',
role: 'date',
frame: { x: 0.0762, y: 0.5857, w: 0.4381, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.4,
color: '#222222',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't5',
role: 'body',
frame: { x: 0.0762, y: 0.6571, w: 0.4381, h: 0.2667 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0429,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#222222',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
{
id: 'p6',
role: 'photo:1',
tint: { border: 'ink' },
frame: { x: 0.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't7',
frame: { x: 0.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '•',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
{
id: 'p8',
role: 'photo:2',
tint: { border: 'ink' },
frame: { x: 1.0762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't9',
frame: { x: 1.0762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '••',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
{
id: 'p10',
role: 'photo:3',
tint: { border: 'ink' },
frame: { x: 1.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0014, color: '#222222' },
},
{
id: 't11',
frame: { x: 1.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
rotation: 0,
opacity: 1,
locked: false,
group: 'plates',
kind: 'text',
text: '•••',
font: 'montserrat',
size: 0.0381,
weight: 300,
italic: false,
align: 'left',
leading: 1.4,
tracking: 0.6,
color: '#857d72',
token: 'accent',
binding: null,
overridden: false,
vAlign: 'middle',
},
],
},
];
@@ -0,0 +1,793 @@
import type { BookDecoratedTemplate } from '../template-format';
/**
* More illustrated postcard pages: a large letter cover whose title is
* filled with the photo, an airmail letter spread of words only and a rack
* of seven picture postcards. Drawn in postcard.
*
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
* in the colours and faces of the theme named above; their links let any
* other theme recolour them.
*/
export const MORE_POSTCARD: readonly BookDecoratedTemplate[] = [
{
id: 'postcard-greetings',
labelKey: 'studio.templates.name.postcardGreetings',
kind: 'single',
archetype: 'postcard',
tags: ['postcard', 'large-letter', 'word-art', 'photo-filled', 'sunburst', 'one-photo', 'lively'],
descr:
'A large letter postcard: a soft sunburst behind a dashed border, the year, the title in huge display serif capitals filled with the photo and shadowed in coral, a script line, a star rule, the photo with a wavy edge, lemons and a postmark.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
keep: true,
frame: { x: -0.05, y: -0.0714, w: 1.1, h: 1.1 },
rotation: 0,
opacity: 0.4,
locked: false,
kind: 'shape',
shape: 'sunburst-24',
fill: '#f3d58a',
gradient: 'none',
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: null,
gradient: 'none',
stroke: '#1f2d4d',
strokeToken: 'ink',
strokeWidth: 0.0024,
strokeStyle: 'dashed',
radius: 0.0071,
},
{
id: 't3',
role: 'date',
frame: { x: 0.2619, y: 0.0905, w: 0.4762, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'center',
leading: 1,
tracking: 0.45,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't4',
role: 'title',
fillPhoto: 'photo:1',
tint: { outline: 'ink', shadow: 'accent' },
frame: { x: 0.0762, y: 0.1429, w: 0.8476, h: 0.2857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.3333,
weight: 400,
italic: false,
align: 'center',
leading: 0.9,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
outline: { color: '#1f2d4d', width: 0.0033 },
shadow: { dx: 0.0095, dy: 0.0095, color: '#c8453d', alpha: 1 },
fit: 'shrink',
fitMax: 0.3333,
},
{
id: 't5',
role: 'subtitle',
frame: { x: 0.1143, y: 0.4381, w: 0.7714, h: 0.0857 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1238,
weight: 400,
italic: false,
align: 'center',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1238,
},
{
id: 's6',
tint: { colors: ['ink'] },
frame: { x: 0.3095, y: 0.5238, w: 0.381, h: 0.0653 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'sticker',
stickerId: 'starRule',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 'p7',
role: 'photo:1',
tint: { shadow: 'ink' },
frame: { x: 0.0952, y: 0.6, w: 0.8095, h: 0.3048 },
rotation: 0,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
edge: 'wavy',
edgeSize: 0.0095,
},
{
id: 's8',
tint: { colors: ['accent3', null, 'ink'] },
frame: { x: 0.7476, y: 0.5143, w: 0.1905, h: 0.1905 },
rotation: 18,
opacity: 1,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'lemonBranch',
colors: ['#f3d58a', '#3f8f4f', '#1f2d4d'],
flipX: false,
},
{
id: 's9',
tint: { colors: ['ink'] },
frame: { x: 0.0762, y: 0.8381, w: 0.181, h: 0.1052 },
rotation: -8,
opacity: 0.8,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
],
},
{
id: 'postcard-airmail',
labelKey: 'studio.templates.name.postcardAirmail',
kind: 'inner',
archetype: 'postcard',
tags: ['postcard', 'airmail', 'letter', 'illustrated', 'stamp', 'words-only', 'story', 'calm'],
descr:
'Airmail stationery without photos: striped coral bands at head and foot, the date, a large display serif title, a script line and a wave rule over a drawn church and a passport stamp; on the right a stamp, a postmark and the long story.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
tint: { fg: 'accent', bg: 'paper' },
frame: { x: -0.0143, y: -0.0143, w: 2.0286, h: 0.0572 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#c8453d',
gradient: 'none',
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's2',
tint: { fg: 'accent', bg: 'paper' },
frame: { x: -0.0143, y: 0.9571, w: 2.0286, h: 0.0572 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'shape',
shape: 'rect',
fill: '#c8453d',
gradient: 'none',
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
stroke: null,
token: 'accent',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's3',
keep: true,
frame: { x: 0.5333, y: 0.5524, w: 0.4095, h: 0.4095 },
rotation: 0,
opacity: 0.35,
locked: false,
kind: 'shape',
shape: 'ellipse',
fill: '#ec8f6a',
gradient: 'none',
stroke: null,
token: 'accent2',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 's4',
keep: true,
tint: { colors: [null, 'ink', 'accent2', 'accent3'] },
frame: { x: 0.5714, y: 0.5905, w: 0.3333, h: 0.3333 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'sticker',
stickerId: 'santoriniChurch',
colors: ['#fbfaf5', '#1f2d4d', '#ec8f6a', '#f3d58a'],
flipX: false,
},
{
id: 's5',
tint: { colors: ['ink'] },
frame: { x: 0.0857, y: 0.7143, w: 0.2857, h: 0.2 },
rotation: -12,
opacity: 0.7,
locked: false,
kind: 'sticker',
stickerId: 'passportStamp',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 't6',
role: 'date',
frame: { x: 0.0762, y: 0.1143, w: 0.8476, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'left',
leading: 1,
tracking: 0.42,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't7',
role: 'title',
frame: { x: 0.0762, y: 0.1619, w: 0.8476, h: 0.3238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.381,
weight: 400,
italic: false,
align: 'left',
leading: 0.92,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'top',
fit: 'shrink',
fitMax: 0.381,
},
{
id: 't8',
role: 'subtitle',
frame: { x: 0.0762, y: 0.4952, w: 0.8476, h: 0.0952 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1333,
weight: 400,
italic: false,
align: 'left',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1333,
},
{
id: 's9',
tint: { colors: ['ink'] },
frame: { x: 0.0762, y: 0.6048, w: 0.3, h: 0.0429 },
rotation: 0,
opacity: 1,
locked: false,
group: 'head',
kind: 'sticker',
stickerId: 'waveRule',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 's10',
tint: { colors: ['accent2', 'ink', 'accent3', null] },
frame: { x: 1.7524, y: 0.0952, w: 0.1524, h: 0.1905 },
rotation: 4,
opacity: 1,
locked: false,
group: 'stamp',
kind: 'sticker',
stickerId: 'postageStamp',
colors: ['#ec8f6a', '#1f2d4d', '#f3d58a', '#f7edd5'],
flipX: false,
},
{
id: 's11',
tint: { colors: ['ink'] },
frame: { x: 1.6, y: 0.1238, w: 0.2, h: 0.1163 },
rotation: -10,
opacity: 0.8,
locked: false,
group: 'stamp',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 'l12',
tint: { stroke: 'accent' },
frame: { x: 1.0762, y: 0.2524, w: 0.4571, h: 0.0095 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'line',
points: [
{
x: 0,
y: 0.5,
},
{
x: 1,
y: 0.5,
},
],
curve: 'straight',
stroke: '#c8453d',
strokeWidth: 0.0012,
dash: 'solid',
squiggle: 'none',
startCap: 'none',
endCap: 'none',
wobble: 0,
ornament: 'star',
},
{
id: 't13',
role: 'body',
frame: { x: 1.0762, y: 0.3048, w: 0.8476, h: 0.6095 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0476,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
],
},
{
id: 'postcard-rack',
labelKey: 'studio.templates.name.postcardRack',
kind: 'inner',
archetype: 'postcard',
tags: ['postcard', 'collage', 'pinboard', 'tape', 'tilted', 'seven-photos', 'lively'],
descr:
'Seven photos as picture postcards in white borders, tilted and overlapping, held by tape, one cancelled by a postmark; a pale sun behind the date, the display serif title and the script line, the story at the foot.',
background: '#e3f1e8',
backgroundToken: 'paper',
elements: [
{
id: 's1',
keep: true,
frame: { x: -0.12, y: -0.14, w: 0.5, h: 0.5102 },
rotation: 0,
opacity: 0.55,
locked: false,
kind: 'shape',
shape: 'sun-cutout',
fill: '#f3d58a',
gradient: 'none',
stroke: null,
token: 'accent3',
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
},
{
id: 't2',
role: 'date',
frame: { x: 0.0762, y: 0.0857, w: 0.8476, h: 0.0381 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'montserrat',
size: 0.0381,
weight: 600,
italic: false,
align: 'left',
leading: 1,
tracking: 0.42,
color: '#1f2d4d',
token: 'ink',
fontRole: 'heading',
binding: null,
overridden: false,
transform: 'upper',
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.0381,
},
{
id: 't3',
role: 'title',
frame: { x: 0.0762, y: 0.1333, w: 0.8476, h: 0.1238 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dmserif',
size: 0.2286,
weight: 400,
italic: false,
align: 'left',
leading: 1,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'display',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.2286,
},
{
id: 't4',
role: 'subtitle',
frame: { x: 0.0762, y: 0.2571, w: 0.8476, h: 0.0762 },
rotation: 0,
opacity: 1,
locked: false,
group: 'title',
kind: 'text',
text: '',
font: 'dancing',
size: 0.1048,
weight: 400,
italic: false,
align: 'left',
leading: 1.1,
tracking: 0,
color: '#c8453d',
token: 'accent',
fontRole: 'script',
binding: null,
overridden: false,
vAlign: 'middle',
fit: 'shrink',
fitMax: 0.1048,
},
{
id: 'p5',
role: 'photo:1',
tint: { shadow: 'ink' },
frame: { x: 0.0762, y: 0.381, w: 0.5238, h: 0.3714 },
rotation: -3,
opacity: 1,
locked: false,
group: 'print',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's6',
tint: { colors: ['accent3'] },
frame: { x: 0.2619, y: 0.3429, w: 0.1524, h: 0.0762 },
rotation: -8,
opacity: 0.85,
locked: false,
group: 'print',
kind: 'sticker',
stickerId: 'maskingTape',
colors: ['#f3d58a'],
flipX: false,
},
{
id: 'p7',
role: 'photo:2',
tint: { shadow: 'ink' },
frame: { x: 0.6, y: 0.3333, w: 0.3048, h: 0.381 },
rotation: 4,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p8',
role: 'photo:3',
tint: { shadow: 'ink' },
frame: { x: 0.4, y: 0.6857, w: 0.3333, h: 0.2381 },
rotation: 2,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p9',
role: 'photo:4',
tint: { shadow: 'ink' },
frame: { x: 1.0762, y: 0.0762, w: 0.4, h: 0.2857 },
rotation: 3,
opacity: 1,
locked: false,
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 'p10',
role: 'photo:5',
tint: { shadow: 'ink' },
frame: { x: 1.5048, y: 0.1048, w: 0.4, h: 0.2857 },
rotation: -4,
opacity: 1,
locked: false,
group: 'tape',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's11',
tint: { colors: ['accent3'] },
frame: { x: 1.6286, y: 0.0667, w: 0.1524, h: 0.0762 },
rotation: 6,
opacity: 0.85,
locked: false,
group: 'tape',
kind: 'sticker',
stickerId: 'maskingTape',
colors: ['#f3d58a'],
flipX: false,
},
{
id: 'p12',
role: 'photo:6',
tint: { shadow: 'ink' },
frame: { x: 1.0952, y: 0.4095, w: 0.3048, h: 0.381 },
rotation: -2,
opacity: 1,
locked: false,
group: 'picture',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's13',
tint: { colors: ['accent2', 'paper'] },
frame: { x: 1.0571, y: 0.3905, w: 0.1905, h: 0.054 },
rotation: -30,
opacity: 0.9,
locked: false,
group: 'picture',
kind: 'sticker',
stickerId: 'washiStripes',
colors: ['#ec8f6a', '#e3f1e8'],
flipX: false,
},
{
id: 'p14',
role: 'photo:7',
tint: { shadow: 'ink' },
frame: { x: 1.4381, y: 0.4286, w: 0.4667, h: 0.3143 },
rotation: 5,
opacity: 1,
locked: false,
group: 'postmark',
kind: 'photo',
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
border: { width: 0.0105, color: '#ffffff' },
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
},
{
id: 's15',
tint: { colors: ['ink'] },
frame: { x: 1.7238, y: 0.6762, w: 0.181, h: 0.1052 },
rotation: -12,
opacity: 0.8,
locked: false,
group: 'postmark',
kind: 'sticker',
stickerId: 'postmark',
colors: ['#1f2d4d'],
flipX: false,
},
{
id: 't16',
role: 'body',
frame: { x: 1.0762, y: 0.8286, w: 0.8476, h: 0.1143 },
rotation: 0,
opacity: 1,
locked: false,
kind: 'text',
text: '',
font: 'inter',
size: 0.0429,
weight: 400,
italic: false,
align: 'left',
leading: 1.55,
tracking: 0,
color: '#1f2d4d',
token: 'ink',
fontRole: 'body',
binding: null,
overridden: false,
vAlign: 'top',
},
],
},
];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -24,7 +24,13 @@ import {
templatePage,
type LaidElement,
} from './template-lay';
import { assignTemplateTexts, textRolesOf, type BookTemplateWord, type BookTemplateWords } from './template-words';
import {
assignTemplateTexts,
isTemplateFigureText,
textRolesOf,
type BookTemplateWord,
type BookTemplateWords,
} from './template-words';
/**
* Laying a page out on a decorated template.
@@ -146,7 +152,9 @@ function poolOf(spread: BookSpread): Pool {
const all = [...spread.elements.filter((e) => !e.locked), ...spread.parked];
return {
photos: all.filter((e): e is BookPhotoElement => e.kind === 'photo' && e.photoId != null),
texts: all.filter((e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e)),
texts: all.filter(
(e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e) && !isTemplateFigureText(e),
),
map: all.find((e): e is BookMapElement => e.kind === 'map'),
marks: all.filter((e): e is BookBadgeElement => e.kind === 'badge'),
};
@@ -55,12 +55,12 @@ function box(el: BookElement) {
const camel = (id: string) => id.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
describe('the decorated template set', () => {
it('TPLCAT-001: holds 21 covers, three in each archetype, and 16 double spreads', () => {
expect(covers).toHaveLength(21);
expect(spreads).toHaveLength(16);
it('TPLCAT-001: holds 48 covers and 60 double spreads, four covers and five spreads in each archetype', () => {
expect(covers).toHaveLength(48);
expect(spreads).toHaveLength(60);
for (const archetype of BOOK_COVER_ARCHETYPES) {
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(3);
expect(decoratedTemplatesFor('inner', archetype).length, archetype).toBeGreaterThanOrEqual(1);
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(4);
expect(decoratedTemplatesFor('inner', archetype), archetype).toHaveLength(5);
}
});
@@ -190,7 +190,7 @@ describe('finding the templates', () => {
expect(findDecoratedTemplate('no-such-template')).toBeUndefined();
expect(decoratedTemplatesFor('single').every((t) => t.kind === 'single')).toBe(true);
expect(decoratedTemplatesFor('inner', 'postcard').every((t) => t.archetype === 'postcard')).toBe(true);
expect(decoratedTemplatesFor('single', null)).toHaveLength(21);
expect(decoratedTemplatesFor('single', null)).toHaveLength(48);
expect(isBookTemplateId('grid-badge-circle')).toBe(true);
expect(isBookTemplateId('two-up')).toBe(true);
expect(isBookTemplateId('cover-full')).toBe(true);
@@ -203,10 +203,10 @@ describe('finding the templates', () => {
expect(row.roles).toEqual(['title', 'date', 'body']);
expect(row.tags).toContain('word-art');
const all = listBookLayouts(null, square);
expect(all.decorated.single).toHaveLength(21);
expect(all.decorated.inner).toHaveLength(16);
expect(all.decorated.single).toHaveLength(48);
expect(all.decorated.inner).toHaveLength(60);
expect(all.notes.decorated).toMatch(/whole designed page/);
const inner = listBookLayouts(null, square, 'inner');
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 16]);
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 60]);
});
});
@@ -12,6 +12,18 @@ import { INNER_COLLAGE } from './data/inner-collage.data';
import { INNER_EPHEMERA } from './data/inner-ephemera.data';
import { INNER_GRIDS } from './data/inner-grids.data';
import { INNER_STORY } from './data/inner-story.data';
import { MORE_CUTOUT } from './data/more-cutout.data';
import { MORE_GRID_BADGE } from './data/more-grid-badge.data';
import { MORE_MINIMAL_SERIF } from './data/more-minimal-serif.data';
import { MORE_POSTCARD } from './data/more-postcard.data';
import { MORE_POSTER_POP } from './data/more-poster-pop.data';
import { MORE_SPINE_BAND } from './data/more-spine-band.data';
import { MORE_STAMP_COLLAGE } from './data/more-stamp-collage.data';
import { NEW_EDITORIAL } from './data/new-editorial.data';
import { NEW_FIELD_NOTES } from './data/new-field-notes.data';
import { NEW_NIGHT_FILM } from './data/new-night-film.data';
import { NEW_RISO } from './data/new-riso.data';
import { NEW_VINTAGE_POSTER } from './data/new-vintage-poster.data';
import {
templatePhotoSlots,
templateRoles,
@@ -19,26 +31,56 @@ import {
type BookTemplateKind,
} from './template-format';
/** The covers or the spreads of a data file that holds both. */
function ofKind(set: readonly BookDecoratedTemplate[], kind: BookTemplateKind): BookDecoratedTemplate[] {
return set.filter((t) => t.kind === kind);
}
/**
* The decorated templates the Studio offers and the assistant lays pages
* on: 21 covers, three in each of the seven archetypes, and 16 double
* spreads, each set in the manner of one of them. The data files hold them
* as drawn (template-format.ts); this is the one list the Studio's panel,
* the page operations and `list_book_layouts` read.
* on: 48 covers, four in each of the twelve archetypes, and 60 double
* spreads, five in the manner of each. The data files hold them as drawn
* (template-format.ts); this is the one list the Studio's panel, the page
* operations and `list_book_layouts` read. Its order is the panel's: the
* covers archetype by archetype, then the spreads.
*/
export const BOOK_DECORATED_TEMPLATES: readonly BookDecoratedTemplate[] = [
...COVERS_SPINE_BAND,
...ofKind(MORE_SPINE_BAND, 'single'),
...COVERS_POSTER_POP,
...ofKind(MORE_POSTER_POP, 'single'),
...COVERS_CUTOUT,
...ofKind(MORE_CUTOUT, 'single'),
...COVERS_POSTCARD,
...ofKind(MORE_POSTCARD, 'single'),
...COVERS_GRID_BADGE,
...ofKind(MORE_GRID_BADGE, 'single'),
...COVERS_MINIMAL_SERIF,
...ofKind(MORE_MINIMAL_SERIF, 'single'),
...COVERS_STAMP_COLLAGE,
...ofKind(MORE_STAMP_COLLAGE, 'single'),
...ofKind(NEW_RISO, 'single'),
...ofKind(NEW_EDITORIAL, 'single'),
...ofKind(NEW_VINTAGE_POSTER, 'single'),
...ofKind(NEW_NIGHT_FILM, 'single'),
...ofKind(NEW_FIELD_NOTES, 'single'),
...INNER_STORY,
...INNER_CHAPTERS,
...INNER_COLLAGE,
...INNER_EPHEMERA,
...INNER_GRIDS,
...ofKind(MORE_SPINE_BAND, 'inner'),
...ofKind(MORE_POSTER_POP, 'inner'),
...ofKind(MORE_CUTOUT, 'inner'),
...ofKind(MORE_POSTCARD, 'inner'),
...ofKind(MORE_GRID_BADGE, 'inner'),
...ofKind(MORE_MINIMAL_SERIF, 'inner'),
...ofKind(MORE_STAMP_COLLAGE, 'inner'),
...ofKind(NEW_RISO, 'inner'),
...ofKind(NEW_EDITORIAL, 'inner'),
...ofKind(NEW_VINTAGE_POSTER, 'inner'),
...ofKind(NEW_NIGHT_FILM, 'inner'),
...ofKind(NEW_FIELD_NOTES, 'inner'),
];
const BY_ID = new Map(BOOK_DECORATED_TEMPLATES.map((t) => [t.id, t]));
@@ -1,10 +1,10 @@
import { bookSpreadSchema, bookTextElementSchema, type BookSpread, type BookTextElement } from '../book.schema';
import type { BookTemplateTextRole } from './template-format';
import { assignTemplateTexts, bookTemplateWords } from './template-words';
import { assignTemplateTexts, bookTemplateWords, isTemplateFigureText } from './template-words';
import { describe, expect, it } from 'vitest';
// TPLWORDS-001 to TPLWORDS-006
// TPLWORDS-001 to TPLWORDS-007
/**
* Which of a page's words a template's roles take: the journal's bindings
@@ -57,6 +57,15 @@ describe('the page words in the roles', () => {
const spare = assignTemplateTexts([text('a', 'One'), text('b', 'Two'), text('c', 'Three')], new Set(['title']));
expect(ids(spare)).toEqual({ title: 'a' });
});
it('TPLWORDS-007: takes the figures a template drew for its design, never a date or a bound text', () => {
expect(isTemplateFigureText(text('tc', '00:12:48:03'))).toBe(true);
expect(isTemplateFigureText(text('frame', '21'))).toBe(true);
expect(isTemplateFigureText(text('day', '12.08.2025'))).toBe(false);
expect(isTemplateFigureText(text('year', '2027'))).toBe(false);
expect(isTemplateFigureText(text('word', 'REC'))).toBe(false);
expect(isTemplateFigureText(text('bound', '14', { binding: { source: 'entry.title', entryId: 1 } }))).toBe(false);
});
});
describe('the book words for the roles a page has none for', () => {
@@ -59,6 +59,15 @@ const SPARE_ORDER: readonly BookTemplateTextRole[] = ['subtitle', 'date', 'capti
const DATE_LIKE = /\b(1[89]|20)\d{2}\b|\b\d{1,2}[./]\d{1,2}\b/;
const isText = (e: BookElement | null | undefined): e is TextEl => !!e && e.kind === 'text' && !!e.text.trim();
/**
* Figures a template drew as its design (a film's timecode, frame numbers, a
* plate's number): no letters and not a date, and bound to nothing. They are
* not the page's words, so a template laid next never takes one for a title
* or a date line.
*/
export const isTemplateFigureText = (e: BookElement): boolean =>
e.kind === 'text' && !e.binding && !/\p{L}/u.test(e.text) && !DATE_LIKE.test(e.text);
const isLong = (t: TextEl) => t.text.trim().length > SHORT;
/** The role a short text reads as on its own: a date line when it says a date, else a subtitle. */
@@ -53,6 +53,11 @@ export const BOOK_THEME_IDS = [
'nordic',
'alpine',
'india-stamp',
'riso',
'magazine',
'retro-travel',
'midnight',
'field-notes',
] as const;
export type BookThemeId = (typeof BOOK_THEME_IDS)[number];
+2 -2
View File
@@ -27,9 +27,9 @@ import { describe, expect, it } from 'vitest';
// BKTHEME-001 to BKTHEME-010
describe('the built-in themes', () => {
it('BKTHEME-001: are twelve, one per id, in the order of the ids', () => {
it('BKTHEME-001: are seventeen, one per id, in the order of the ids', () => {
expect(BOOK_THEME_PRESETS.map((p) => p.id)).toEqual([...BOOK_THEME_IDS]);
expect(BOOK_THEME_IDS).toHaveLength(12);
expect(BOOK_THEME_IDS).toHaveLength(17);
for (const id of BOOK_THEME_IDS) expect(bookThemeOf(id).id).toBe(id);
expect(bookThemePreset('nope')).toBeUndefined();
expect(() => bookThemeOf('nope' as never)).toThrow();
+87 -1
View File
@@ -8,7 +8,7 @@ import type {
} from './book-theme.schema';
/**
* The twelve built-in themes, each a look of its own rather than a recolour:
* The seventeen built-in themes, each a look of its own rather than a recolour:
* a paper and an ink that read together, three accents that belong to the
* same place, a display face for the cover, a heading and a body face that
* pair, a script for the words laid over a photo, and the motifs that suit.
@@ -253,4 +253,90 @@ export const BOOK_THEME_PRESETS: readonly BookThemePreset[] = [
frameStyle: 'white',
tilt: 2,
},
{
id: 'riso',
labelKey: 'studio.theme.name.riso',
descrKey: 'studio.theme.descr.riso',
descr: 'Riso print: cream paper, riso blue, fluo pink, yellow and teal inks overprinted, a bold grotesque.',
palette: { paper: '#f4efe4', ink: '#23306b', accent: '#ed4981', accent2: '#ffd23f', accent3: '#00a39a' },
fonts: {
display: { font: 'bricolage', weight: 800 },
heading: { font: 'montserrat', weight: 700 },
body: { font: 'inter' },
script: { font: 'marker', weight: 400 },
},
motifs: ['doodles', 'paper'],
decoration: 'rich',
frameStyle: 'none',
tilt: 2,
},
{
id: 'magazine',
labelKey: 'studio.theme.name.magazine',
descrKey: 'studio.theme.descr.magazine',
descr: 'Travel magazine: off white paper, black ink, one signal red, a huge Bodoni masthead over Garamond.',
palette: { paper: '#f7f4ee', ink: '#111111', accent: '#d7261e', accent2: '#e9e2d6', accent3: '#6b6b6b' },
fonts: {
display: { font: 'bodoni', weight: 800 },
heading: { font: 'montserrat', weight: 600 },
body: { font: 'garamond' },
script: null,
},
motifs: ['ornaments'],
decoration: 'light',
frameStyle: 'none',
tilt: 0,
},
{
id: 'retro-travel',
labelKey: 'studio.theme.name.retroTravel',
descrKey: 'studio.theme.descr.retroTravel',
descr:
'Retro travel poster: aged cream paper, brown ink, orange, teal and mustard, a slab title over condensed caps.',
palette: { paper: '#f3e7cf', ink: '#3b2a1e', accent: '#d56129', accent2: '#2f7f7a', accent3: '#f2b33d' },
fonts: {
display: { font: 'alfa', weight: 400 },
heading: { font: 'oswald', weight: 500 },
body: { font: 'serif' },
script: { font: 'yellowtail', weight: 400 },
},
motifs: ['mountains', 'mediterranean', 'landmarks'],
decoration: 'rich',
frameStyle: 'white',
tilt: 0,
},
{
id: 'midnight',
labelKey: 'studio.theme.name.midnight',
descrKey: 'studio.theme.descr.midnight',
descr: 'Midnight film: near black paper, warm white ink, neon orange and cyan, tall condensed capitals.',
palette: { paper: '#121417', ink: '#f2efe8', accent: '#ff6b3d', accent2: '#39d0c4', accent3: '#8a8f98' },
fonts: {
display: { font: 'league', weight: 400 },
heading: { font: 'oswald', weight: 500 },
body: { font: 'inter' },
script: { font: 'satisfy', weight: 400 },
},
motifs: ['doodles'],
decoration: 'light',
frameStyle: 'none',
tilt: 0,
},
{
id: 'field-notes',
labelKey: 'studio.theme.name.fieldNotes',
descrKey: 'studio.theme.descr.fieldNotes',
descr: 'Field notes: kraft paper, moss green ink, rust and sage, a soft serif title, handwriting, tape and leaves.',
palette: { paper: '#e9dcc3', ink: '#2f3a2b', accent: '#b5532c', accent2: '#f8f2e4', accent3: '#6f8a5a' },
fonts: {
display: { font: 'fraunces', weight: 700 },
heading: { font: 'montserrat', weight: 600 },
body: { font: 'serif' },
script: { font: 'caveat', weight: 400 },
},
motifs: ['paper', 'mountains', 'nordic'],
decoration: 'rich',
frameStyle: 'polaroid',
tilt: 3,
},
];
-8
View File
@@ -68,7 +68,6 @@ const assistant: TranslationStrings = {
'assistant.connect.quota.two': 'استُخدمت اليوم {used} من رسالتين',
'assistant.connect.quota.few': 'استُخدمت اليوم {used} من {count} رسائل',
'assistant.connect.quota.many': 'استُخدمت اليوم {used} من {count} رسالة',
'assistant.connect.claudeLink': 'تستخدم Claude؟ اربطه من داخل Claude',
'assistant.privacy.what': 'ما الذي تتم مشاركته؟',
'assistant.privacy.banner': 'تُرسل رسائلك وبيانات الرحلة التي يقرؤها المساعد إلى {provider}.',
'assistant.privacy.ok': 'فهمت',
@@ -270,13 +269,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays.few': '{count} أيام',
'assistant.admin.retentionDays.many': '{count} يومًا',
'assistant.admin.retentionForever': 'للأبد',
'assistant.claude.title': 'الربط مع Claude',
'assistant.claude.description': 'استخدم TREK مباشرة داخل Claude: أضفه كموصل، وسجّل الدخول، وانتهى الأمر.',
'assistant.claude.open': 'فتح في Claude',
'assistant.claude.scan': 'أو امسحه بهاتفك',
'assistant.claude.copyUrl': 'نسخ العنوان',
'assistant.claude.qrAlt': 'رمز QR لإضافة TREK في Claude',
'assistant.claude.notPublic': 'لا يستطيع Claude الوصول إلى TREK إلا إذا كان متاحًا عبر https على الإنترنت.',
'assistant.mobile.tile': 'اسأل TREK',
'assistant.mobile.tileHint': 'خطط مع المساعد',
'assistant.mobile.backToChat': 'العودة إلى المحادثة',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'شبكة وشارة',
'studio.templates.archetype.minimal-serif': 'بسيط',
'studio.templates.archetype.stamp-collage': 'طوابع',
'studio.templates.archetype.riso': 'ريزو',
'studio.templates.archetype.editorial': 'مجلة',
'studio.templates.archetype.vintage-poster': 'ملصق قديم',
'studio.templates.archetype.night-film': 'فيلم ليلي',
'studio.templates.archetype.field-notes': 'ملاحظات ميدانية',
'studio.templates.name.spineBandStitched': 'شريط مخيط',
'studio.templates.name.spineBandGingham': 'شريط مربعات',
'studio.templates.name.spineBandScript': 'شريط وخط يدوي',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'شبكة بصورة رئيسية',
'studio.templates.name.gridColumns': 'شبكة أعمدة',
'studio.templates.name.gridMosaic': 'شبكة فسيفساء',
'studio.templates.name.spineBandPlate': 'لوحة ملصقة',
'studio.templates.name.posterPopSunDisc': 'قرص الشمس',
'studio.templates.name.cutoutCircle': 'دائرة مقصوصة',
'studio.templates.name.postcardGreetings': 'بطاقة تحية بريدية',
'studio.templates.name.gridBadgeTiles': 'شبكة شارات',
'studio.templates.name.minimalSerifCameo': 'نقش بيضاوي',
'studio.templates.name.stampCollageAirmail': 'ظرف بريد جوي',
'studio.templates.name.risoOverprint': 'ملصق بطباعة متراكبة',
'studio.templates.name.risoHalftoneSun': 'شمس نقطية',
'studio.templates.name.risoZineStack': 'كومة مجلات صغيرة',
'studio.templates.name.risoPinkPress': 'طباعة وردية',
'studio.templates.name.editorialMasthead': 'غلاف بترويسة',
'studio.templates.name.editorialCoverStory': 'قصة الغلاف',
'studio.templates.name.editorialSplit': 'صورة فوق العنوان',
'studio.templates.name.editorialHeadline': 'غلاف بعنوان عريض',
'studio.templates.name.vintagePosterPanorama': 'بانوراما ملصق',
'studio.templates.name.vintagePosterArch': 'قوس وبحر',
'studio.templates.name.vintagePosterSunDisc': 'شمس فوق التلال',
'studio.templates.name.vintagePosterPeaks': 'قمم متراصة',
'studio.templates.name.nightFilmLetterbox': 'غلاف بشاشة عريضة',
'studio.templates.name.nightFilmStrip': 'غلاف بشريط فيلم',
'studio.templates.name.nightFilmTitleCard': 'بطاقة العنوان',
'studio.templates.name.nightFilmFadeOut': 'تلاشٍ إلى السواد',
'studio.templates.name.fieldNotesTorn': 'ورقة دفتر ممزقة',
'studio.templates.name.fieldNotesPolaroid': 'بولارويد بشريط لاصق',
'studio.templates.name.fieldNotesSpecimens': 'عينات بشريط لاصق',
'studio.templates.name.fieldNotesLabel': 'ملصق الدفتر',
'studio.templates.name.spineBandClothStory': 'قصة على القماش',
'studio.templates.name.spineBandGinghamTrio': 'ثلاثية بكعب مربعات',
'studio.templates.name.spineBandAlbum': 'ألبوم مجلد',
'studio.templates.name.posterPopHeadline': 'عنوان ملصق',
'studio.templates.name.posterPopPostcards': 'صور مائلة',
'studio.templates.name.posterPopPhotoWall': 'جدار صور',
'studio.templates.name.cutoutStoryPage': 'قصة مقصوصة',
'studio.templates.name.cutoutTrio': 'ثلاثية مقصوصة',
'studio.templates.name.cutoutScatter': 'صور متناثرة',
'studio.templates.name.cutoutMosaic': 'فسيفساء مقصوصة',
'studio.templates.name.postcardAirmail': 'رسالة بريد جوي',
'studio.templates.name.postcardRack': 'حامل بطاقات بريدية',
'studio.templates.name.gridBadgeLetter': 'ختم وقصة',
'studio.templates.name.gridBadgeHeroSix': 'صورة رئيسية وست',
'studio.templates.name.minimalSerifEssay': 'مقالة هادئة',
'studio.templates.name.minimalSerifTriptych': 'ثلاث لوحات',
'studio.templates.name.stampCollageLetter': 'رسالة مختومة',
'studio.templates.name.stampCollageArchStamps': 'قوس وطوابع',
'studio.templates.name.stampCollageSheet': 'ورقة طوابع',
'studio.templates.name.risoStoryPlate': 'قصة ولوحة',
'studio.templates.name.risoOffsetPair': 'زوج مزاح',
'studio.templates.name.risoBlobTrio': 'ثلاثية بقع',
'studio.templates.name.risoZineGrid': 'شبكة مجلة صغيرة',
'studio.templates.name.risoPrintScatter': 'طبعات منثورة',
'studio.templates.name.editorialFeatureOpener': 'افتتاحية تحقيق',
'studio.templates.name.editorialPullQuote': 'اقتباس بارز',
'studio.templates.name.editorialFeatureColumns': 'تحقيق في أعمدة',
'studio.templates.name.editorialPortfolio': 'صفحتا معرض أعمال',
'studio.templates.name.editorialContactSheet': 'ورقة تلامس',
'studio.templates.name.vintagePosterStory': 'ملصق وقصة',
'studio.templates.name.vintagePosterTwin': 'ملصقان توأمان',
'studio.templates.name.vintagePosterArcade': 'رواق أقواس',
'studio.templates.name.vintagePosterPanels': 'ألواح وتلال',
'studio.templates.name.vintagePosterLabels': 'بطاقات أمتعة',
'studio.templates.name.nightFilmOpening': 'المشهد الافتتاحي',
'studio.templates.name.nightFilmSubtitles': 'ترجمة الفيلم',
'studio.templates.name.nightFilmReels': 'تلاشٍ وبكرات',
'studio.templates.name.nightFilmContact': 'ورقة تلامس الفيلم',
'studio.templates.name.nightFilmDoubleReel': 'بكرة مزدوجة',
'studio.templates.name.fieldNotesDiary': 'صفحة يوميات',
'studio.templates.name.fieldNotesTrail': 'درب بطاقات ملاحظات',
'studio.templates.name.fieldNotesRoute': 'مسار وصور بولارويد',
'studio.templates.name.fieldNotesContact': 'ورقة تلامس مرقمة',
'studio.templates.name.fieldNotesHerbarium': 'أوراق معشبة',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'شمالي',
'studio.theme.name.alpine': 'جبال الألب',
'studio.theme.name.indiaStamp': 'مجموعة طوابع',
'studio.theme.name.riso': 'طباعة ريزو',
'studio.theme.name.magazine': 'مجلة',
'studio.theme.name.retroTravel': 'سفر قديم الطراز',
'studio.theme.name.midnight': 'منتصف الليل',
'studio.theme.name.fieldNotes': 'ملاحظات ميدانية',
'studio.theme.descr.posterPop': 'ورق كريمي ومرجاني ودوّار الشمس وعناوين ملصقات عريضة',
'studio.theme.descr.cutout': 'ورق كوبالتي وأشكال بلون المرجان والرمل وكتابة بيضاء',
'studio.theme.descr.postcard': 'ورق بلون النعناع وحبر كحلي وأحمر الختم وعنوان فرعي بخط اليد',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'ورق رمادي فاتح مع لون الأردواز والمغرة والوردي',
'studio.theme.descr.alpine': 'أبيض ثلجي مع أخضر الغابات ولون الصدأ وأزرق السماء',
'studio.theme.descr.indiaStamp': 'ورق دافئ مع الأرجواني والزعفراني والأزرق المخضر',
'studio.theme.descr.riso': 'ورق كريمي وأزرق ريزو ووردي فسفوري مطبوعان فوق بعضهما',
'studio.theme.descr.magazine': 'ورق أبيض مائل للصفرة وحبر أسود ولون أحمر وترويسة كبيرة',
'studio.theme.descr.retroTravel': 'ورق كريمي معتق بالبرتقالي والأخضر المزرق والخردلي',
'studio.theme.descr.midnight': 'ورق شبه أسود ونص أبيض دافئ ولمسات نيون',
'studio.theme.descr.fieldNotes': 'ورق كرافت وأخضر طحلبي وصدئ وخط يد وشريط لاصق',
'studio.theme.panel': 'السمة',
'studio.theme.intro': 'اختر مظهرًا للكتاب كله. الألوان والخطوط التي تتبع السمة تتغير معها، وكل ما عداها يبقى كما هو.',
'studio.theme.themes': 'السمات',
-9
View File
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'Əlçatan deyil',
'assistant.connect.quota': 'Bu gün {count} mesajdan {used} istifadə olunub',
'assistant.connect.quota.one': 'Bu gün {count} mesajdan {used} istifadə olunub',
'assistant.connect.claudeLink': 'Claude istifadə edirsiniz? Claude-da qoşulun',
'assistant.privacy.what': 'Nə paylaşılır?',
'assistant.privacy.banner': 'Mesajlarınız və köməkçinin oxuduğu səyahət məlumatları {provider} xidmətinə göndərilir.',
'assistant.privacy.ok': 'Aydındır',
@@ -253,14 +252,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} gün',
'assistant.admin.retentionDays.one': '{count} gün',
'assistant.admin.retentionForever': 'Həmişəlik',
'assistant.claude.title': 'Claude ilə qoşul',
'assistant.claude.description':
'TREK-dən birbaşa Claude daxilində istifadə edin: onu konnektor kimi əlavə edin, daxil olun, hazırdır.',
'assistant.claude.open': 'Claude-da aç',
'assistant.claude.scan': 'Və ya telefonunuzla skan edin',
'assistant.claude.copyUrl': 'Ünvanı kopyala',
'assistant.claude.qrAlt': 'TREK-i Claude-a əlavə etmək üçün QR kod',
'assistant.claude.notPublic': 'Claude TREK-ə yalnız o, internetdə https ilə əlçatan olduqda çata bilər.',
'assistant.mobile.tile': 'TREK-dən soruş',
'assistant.mobile.tileHint': 'Köməkçi ilə planlaşdırın',
'assistant.mobile.backToChat': 'Söhbətə qayıt',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Şəbəkə və nişan',
'studio.templates.archetype.minimal-serif': 'Minimal', // same-as-en
'studio.templates.archetype.stamp-collage': 'Markalar',
'studio.templates.archetype.riso': 'Rizo',
'studio.templates.archetype.editorial': 'Jurnal',
'studio.templates.archetype.vintage-poster': 'Retro afişa',
'studio.templates.archetype.night-film': 'Gecə filmi',
'studio.templates.archetype.field-notes': 'Səyahət qeydləri',
'studio.templates.name.spineBandStitched': 'Tikişli zolaq',
'studio.templates.name.spineBandGingham': 'Damalı zolaq',
'studio.templates.name.spineBandScript': 'Zolaq və əl yazısı',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Əsas fotolu şəbəkə',
'studio.templates.name.gridColumns': 'Sütunlu şəbəkə',
'studio.templates.name.gridMosaic': 'Mozaika şəbəkəsi',
'studio.templates.name.spineBandPlate': 'Yapışdırılmış lövhə',
'studio.templates.name.posterPopSunDisc': 'Günəş diski',
'studio.templates.name.cutoutCircle': 'Kəsilmiş dairə',
'studio.templates.name.postcardGreetings': 'Salam açıqcası',
'studio.templates.name.gridBadgeTiles': 'Nişan şəbəkəsi',
'studio.templates.name.minimalSerifCameo': 'Oval kameya',
'studio.templates.name.stampCollageAirmail': 'Hava poçtu zərfi',
'studio.templates.name.risoOverprint': 'Üst-üstə çap plakatı',
'studio.templates.name.risoHalftoneSun': 'Rastrlı günəş',
'studio.templates.name.risoZineStack': 'Zin yığını',
'studio.templates.name.risoPinkPress': 'Çəhrayı çap',
'studio.templates.name.editorialMasthead': 'Başlıqlı üz qabığı',
'studio.templates.name.editorialCoverStory': 'Üz qabığı hekayəsi',
'studio.templates.name.editorialSplit': 'Başlıq üzərində foto',
'studio.templates.name.editorialHeadline': 'Manşetli üz qabığı',
'studio.templates.name.vintagePosterPanorama': 'Plakat panoraması',
'studio.templates.name.vintagePosterArch': 'Tağ və dəniz',
'studio.templates.name.vintagePosterSunDisc': 'Təpələr üzərində günəş',
'studio.templates.name.vintagePosterPeaks': 'Pilləli zirvələr',
'studio.templates.name.nightFilmLetterbox': 'Geniş ekranlı üz qabığı',
'studio.templates.name.nightFilmStrip': 'Kino lentli üz qabığı',
'studio.templates.name.nightFilmTitleCard': 'Başlıq kartı',
'studio.templates.name.nightFilmFadeOut': 'Qaranlığa keçid',
'studio.templates.name.fieldNotesTorn': 'Cırılmış dəftər vərəqi',
'studio.templates.name.fieldNotesPolaroid': 'Lentlə yapışdırılmış polaroid',
'studio.templates.name.fieldNotesSpecimens': 'Lentlə yapışdırılmış nümunələr',
'studio.templates.name.fieldNotesLabel': 'Dəftər etiketi',
'studio.templates.name.spineBandClothStory': 'Parça üzərində hekayə',
'studio.templates.name.spineBandGinghamTrio': 'Damalı cildli üçlük',
'studio.templates.name.spineBandAlbum': 'Cildlənmiş albom',
'studio.templates.name.posterPopHeadline': 'Plakat manşeti',
'studio.templates.name.posterPopPostcards': 'Əyilmiş fotolar',
'studio.templates.name.posterPopPhotoWall': 'Foto divarı',
'studio.templates.name.cutoutStoryPage': 'Kəsilmiş hekayə',
'studio.templates.name.cutoutTrio': 'Kəsilmiş üçlük',
'studio.templates.name.cutoutScatter': 'Səpələnmiş fotolar',
'studio.templates.name.cutoutMosaic': 'Kəsilmiş mozaika',
'studio.templates.name.postcardAirmail': 'Hava poçtu məktubu',
'studio.templates.name.postcardRack': 'Açıqca rəfi',
'studio.templates.name.gridBadgeLetter': 'Möhür və hekayə',
'studio.templates.name.gridBadgeHeroSix': 'Əsas foto və altı',
'studio.templates.name.minimalSerifEssay': 'Sakit esse',
'studio.templates.name.minimalSerifTriptych': 'Üç lövhə',
'studio.templates.name.stampCollageLetter': 'Möhürlənmiş məktub',
'studio.templates.name.stampCollageArchStamps': 'Tağ və markalar',
'studio.templates.name.stampCollageSheet': 'Marka vərəqi',
'studio.templates.name.risoStoryPlate': 'Hekayə və lövhə',
'studio.templates.name.risoOffsetPair': 'Sürüşmüş cüt',
'studio.templates.name.risoBlobTrio': 'Ləkə üçlüyü',
'studio.templates.name.risoZineGrid': 'Zin şəbəkəsi',
'studio.templates.name.risoPrintScatter': 'Dağınıq çaplar',
'studio.templates.name.editorialFeatureOpener': 'Reportaj girişi',
'studio.templates.name.editorialPullQuote': 'Seçilmiş sitat',
'studio.templates.name.editorialFeatureColumns': 'Sütunlarda reportaj',
'studio.templates.name.editorialPortfolio': 'Portfolio səhifə cütü',
'studio.templates.name.editorialContactSheet': 'Kontakt vərəqi',
'studio.templates.name.vintagePosterStory': 'Plakat və hekayə',
'studio.templates.name.vintagePosterTwin': 'Əkiz plakatlar',
'studio.templates.name.vintagePosterArcade': 'Tağlı qalereya',
'studio.templates.name.vintagePosterPanels': 'Panellər və təpələr',
'studio.templates.name.vintagePosterLabels': 'Baqaj etiketləri',
'studio.templates.name.nightFilmOpening': 'Açılış səhnəsi',
'studio.templates.name.nightFilmSubtitles': 'Film altyazıları',
'studio.templates.name.nightFilmReels': 'Keçid və çarxlar',
'studio.templates.name.nightFilmContact': 'Film kontakt vərəqi',
'studio.templates.name.nightFilmDoubleReel': 'İkiqat çarx',
'studio.templates.name.fieldNotesDiary': 'Gündəlik səhifəsi',
'studio.templates.name.fieldNotesTrail': 'Qeyd kartları izi',
'studio.templates.name.fieldNotesRoute': 'Marşrut və polaroidlər',
'studio.templates.name.fieldNotesContact': 'Nömrəli kontakt vərəqi',
'studio.templates.name.fieldNotesHerbarium': 'Herbari vərəqləri',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Şimal',
'studio.theme.name.alpine': 'Alp',
'studio.theme.name.indiaStamp': 'Marka kollajı',
'studio.theme.name.riso': 'Riso çapı',
'studio.theme.name.magazine': 'Jurnal',
'studio.theme.name.retroTravel': 'Retro səyahət',
'studio.theme.name.midnight': 'Gecəyarısı',
'studio.theme.name.fieldNotes': 'Səyahət qeydləri',
'studio.theme.descr.posterPop': 'Krem kağız, mərcan və günəbaxan, qalın afişa başlıqları',
'studio.theme.descr.cutout': 'Kobalt kağız, mərcan və qum rəngli fiqurlar, ağ yazı',
'studio.theme.descr.postcard': 'Nanə rəngli kağız, tünd göy mürəkkəb, möhür qırmızısı və əl yazısı ilə alt başlıq',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Şifer, oxra və çəhrayı ilə açıq boz kağız',
'studio.theme.descr.alpine': 'Meşə yaşılı, pas və səma mavisi ilə qar ağı',
'studio.theme.descr.indiaStamp': 'Bənövşəyi, zəfəran və firuzəyi ilə isti kağız',
'studio.theme.descr.riso': 'Krem kağız, riso mavisi və neon çəhrayı üst üstə',
'studio.theme.descr.magazine': 'Süd ağı kağız, qara mürəkkəb, bir qırmızı və böyük başlıq',
'studio.theme.descr.retroTravel': 'Köhnəlmiş krem kağız, narıncı, firuzəyi və xardal',
'studio.theme.descr.midnight': 'Demək olar ki qara kağız, isti ağ yazı və neon vurğular',
'studio.theme.descr.fieldNotes': 'Kraft kağız, mamır yaşılı və pas, əl yazısı və lent',
'studio.theme.panel': 'Mövzu',
'studio.theme.intro':
'Bütün kitab üçün bir görünüş seçin. Mövzunu izləyən rənglər və yazı onunla birlikdə dəyişir, qalan hər şey olduğu kimi qalır.',
-8
View File
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'Inacessível',
'assistant.connect.quota': '{used} de {count} mensagens usadas hoje',
'assistant.connect.quota.one': '{used} de {count} mensagem usada hoje',
'assistant.connect.claudeLink': 'Usa o Claude? Conecte no Claude',
'assistant.privacy.what': 'O que é compartilhado?',
'assistant.privacy.banner': 'Suas mensagens e os dados da viagem que o assistente lê vão para {provider}.',
'assistant.privacy.ok': 'Entendi',
@@ -253,13 +252,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} dias',
'assistant.admin.retentionDays.one': '{count} dia',
'assistant.admin.retentionForever': 'Para sempre',
'assistant.claude.title': 'Conectar com o Claude',
'assistant.claude.description': 'Use o TREK direto no Claude: adicione como conector, entre e pronto.',
'assistant.claude.open': 'Abrir no Claude',
'assistant.claude.scan': 'Ou escaneie com o celular',
'assistant.claude.copyUrl': 'Copiar endereço',
'assistant.claude.qrAlt': 'QR code para adicionar o TREK no Claude',
'assistant.claude.notPublic': 'O Claude só consegue acessar o TREK se ele estiver disponível via https na internet.',
'assistant.mobile.tile': 'Pergunte ao TREK',
'assistant.mobile.tileHint': 'Planeje com o assistente',
'assistant.mobile.backToChat': 'Voltar ao chat',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Grade e selo',
'studio.templates.archetype.minimal-serif': 'Minimalista',
'studio.templates.archetype.stamp-collage': 'Selos',
'studio.templates.archetype.riso': 'Riso', // same-as-en
'studio.templates.archetype.editorial': 'Revista',
'studio.templates.archetype.vintage-poster': 'Pôster vintage',
'studio.templates.archetype.night-film': 'Filme noturno',
'studio.templates.archetype.field-notes': 'Caderno de campo',
'studio.templates.name.spineBandStitched': 'Faixa costurada',
'studio.templates.name.spineBandGingham': 'Faixa xadrez',
'studio.templates.name.spineBandScript': 'Faixa e caligrafia',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Grade em destaque',
'studio.templates.name.gridColumns': 'Grade em colunas',
'studio.templates.name.gridMosaic': 'Grade mosaico',
'studio.templates.name.spineBandPlate': 'Prancha colada',
'studio.templates.name.posterPopSunDisc': 'Disco de sol',
'studio.templates.name.cutoutCircle': 'Círculo recortado',
'studio.templates.name.postcardGreetings': 'Cartão-postal de saudações',
'studio.templates.name.gridBadgeTiles': 'Grade de emblemas',
'studio.templates.name.minimalSerifCameo': 'Camafeu oval',
'studio.templates.name.stampCollageAirmail': 'Envelope de correio aéreo',
'studio.templates.name.risoOverprint': 'Pôster sobreposto',
'studio.templates.name.risoHalftoneSun': 'Sol em retícula',
'studio.templates.name.risoZineStack': 'Pilha de zines',
'studio.templates.name.risoPinkPress': 'Prensa rosa',
'studio.templates.name.editorialMasthead': 'Capa com cabeçalho',
'studio.templates.name.editorialCoverStory': 'Matéria de capa',
'studio.templates.name.editorialSplit': 'Foto sobre a manchete',
'studio.templates.name.editorialHeadline': 'Capa de manchete',
'studio.templates.name.vintagePosterPanorama': 'Panorama de pôster',
'studio.templates.name.vintagePosterArch': 'Arco e mar',
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre colinas',
'studio.templates.name.vintagePosterPeaks': 'Picos escalonados',
'studio.templates.name.nightFilmLetterbox': 'Capa em tela larga',
'studio.templates.name.nightFilmStrip': 'Capa com tira de filme',
'studio.templates.name.nightFilmTitleCard': 'Cartela de título',
'studio.templates.name.nightFilmFadeOut': 'Fusão para o preto',
'studio.templates.name.fieldNotesTorn': 'Folha de caderno rasgada',
'studio.templates.name.fieldNotesPolaroid': 'Polaroid com fita',
'studio.templates.name.fieldNotesSpecimens': 'Amostras com fita',
'studio.templates.name.fieldNotesLabel': 'Etiqueta de caderno',
'studio.templates.name.spineBandClothStory': 'História no tecido',
'studio.templates.name.spineBandGinghamTrio': 'Trio com lombada xadrez',
'studio.templates.name.spineBandAlbum': 'Álbum encadernado',
'studio.templates.name.posterPopHeadline': 'Manchete de pôster',
'studio.templates.name.posterPopPostcards': 'Fotos inclinadas',
'studio.templates.name.posterPopPhotoWall': 'Mural de fotos',
'studio.templates.name.cutoutStoryPage': 'História recortada',
'studio.templates.name.cutoutTrio': 'Trio recortado',
'studio.templates.name.cutoutScatter': 'Fotos espalhadas',
'studio.templates.name.cutoutMosaic': 'Mosaico recortado',
'studio.templates.name.postcardAirmail': 'Carta por avião',
'studio.templates.name.postcardRack': 'Expositor de postais',
'studio.templates.name.gridBadgeLetter': 'Selo e história',
'studio.templates.name.gridBadgeHeroSix': 'Destaque e seis',
'studio.templates.name.minimalSerifEssay': 'Ensaio tranquilo',
'studio.templates.name.minimalSerifTriptych': 'Três pranchas',
'studio.templates.name.stampCollageLetter': 'Carta carimbada',
'studio.templates.name.stampCollageArchStamps': 'Arco e selos',
'studio.templates.name.stampCollageSheet': 'Folha de selos',
'studio.templates.name.risoStoryPlate': 'História e prancha',
'studio.templates.name.risoOffsetPair': 'Par deslocado',
'studio.templates.name.risoBlobTrio': 'Trio de manchas',
'studio.templates.name.risoZineGrid': 'Grade de zine',
'studio.templates.name.risoPrintScatter': 'Impressões espalhadas',
'studio.templates.name.editorialFeatureOpener': 'Abertura de reportagem',
'studio.templates.name.editorialPullQuote': 'Citação em destaque',
'studio.templates.name.editorialFeatureColumns': 'Reportagem em colunas',
'studio.templates.name.editorialPortfolio': 'Página dupla de portfólio',
'studio.templates.name.editorialContactSheet': 'Folha de contato',
'studio.templates.name.vintagePosterStory': 'Pôster e história',
'studio.templates.name.vintagePosterTwin': 'Pôsteres gêmeos',
'studio.templates.name.vintagePosterArcade': 'Arcada de arcos',
'studio.templates.name.vintagePosterPanels': 'Painéis e colinas',
'studio.templates.name.vintagePosterLabels': 'Etiquetas de bagagem',
'studio.templates.name.nightFilmOpening': 'Cena de abertura',
'studio.templates.name.nightFilmSubtitles': 'Legendas de filme',
'studio.templates.name.nightFilmReels': 'Fusão e rolos',
'studio.templates.name.nightFilmContact': 'Folha de contato de filme',
'studio.templates.name.nightFilmDoubleReel': 'Rolo duplo',
'studio.templates.name.fieldNotesDiary': 'Página de diário',
'studio.templates.name.fieldNotesTrail': 'Trilha de fichas',
'studio.templates.name.fieldNotesRoute': 'Rota e polaroids',
'studio.templates.name.fieldNotesContact': 'Folha de contato numerada',
'studio.templates.name.fieldNotesHerbarium': 'Folhas de herbário',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Nórdico',
'studio.theme.name.alpine': 'Alpino',
'studio.theme.name.indiaStamp': 'Colagem de selos',
'studio.theme.name.riso': 'Impressão riso',
'studio.theme.name.magazine': 'Revista',
'studio.theme.name.retroTravel': 'Viagem retrô',
'studio.theme.name.midnight': 'Noite escura',
'studio.theme.name.fieldNotes': 'Caderno de campo',
'studio.theme.descr.posterPop': 'Papel creme, coral e girassol, títulos de pôster encorpados',
'studio.theme.descr.cutout': 'Papel cobalto, formas em coral e areia, letras brancas',
'studio.theme.descr.postcard': 'Papel menta, tinta azul-marinho, vermelho de carimbo e um subtítulo manuscrito',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Papel cinza-claro com ardósia, ocre e rosa',
'studio.theme.descr.alpine': 'Branco-neve com verde-floresta, ferrugem e azul-céu',
'studio.theme.descr.indiaStamp': 'Papel quente com magenta, açafrão e verde-petróleo',
'studio.theme.descr.riso': 'Papel creme, azul riso e rosa flúor sobrepostos',
'studio.theme.descr.magazine': 'Papel branco gelo, tinta preta, um vermelho e um grande cabeçalho',
'studio.theme.descr.retroTravel': 'Papel creme envelhecido com laranja, verde petróleo e mostarda',
'studio.theme.descr.midnight': 'Papel quase preto, texto branco quente e toques neon',
'studio.theme.descr.fieldNotes': 'Papel kraft, verde musgo e ferrugem, letra à mão e fita',
'studio.theme.panel': 'Tema',
'studio.theme.intro':
'Escolha um visual para o livro todo. As cores e as letras que seguem o tema mudam com ele, todo o resto continua como está.',
-9
View File
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'No accessible',
'assistant.connect.quota': '{used} de {count} missatges utilitzats avui',
'assistant.connect.quota.one': '{used} de {count} missatge utilitzat avui',
'assistant.connect.claudeLink': 'Fas servir Claude? Connecta’t a Claude',
'assistant.privacy.what': 'Què es comparteix?',
'assistant.privacy.banner':
'Els teus missatges i les dades del viatge que llegeix l’assistent s’envien a {provider}.',
@@ -255,14 +254,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} dies',
'assistant.admin.retentionDays.one': '{count} dia',
'assistant.admin.retentionForever': 'Per sempre',
'assistant.claude.title': 'Connecta amb Claude',
'assistant.claude.description':
'Fes servir TREK directament a Claude: afegeix-lo com a connector, inicia sessió i ja està.',
'assistant.claude.open': 'Obre a Claude',
'assistant.claude.scan': 'O escaneja’l amb el mòbil',
'assistant.claude.copyUrl': 'Copia l’adreça',
'assistant.claude.qrAlt': 'Codi QR per afegir TREK a Claude',
'assistant.claude.notPublic': 'Claude només pot accedir a TREK si està disponible per https a internet.',
'assistant.mobile.tile': 'Pregunta a TREK',
'assistant.mobile.tileHint': 'Planifica amb l’assistent',
'assistant.mobile.backToChat': 'Torna al xat',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Quadrícula i insígnia',
'studio.templates.archetype.minimal-serif': 'Minimalista',
'studio.templates.archetype.stamp-collage': 'Segells',
'studio.templates.archetype.riso': 'Riso', // same-as-en
'studio.templates.archetype.editorial': 'Revista',
'studio.templates.archetype.vintage-poster': 'Cartell antic',
'studio.templates.archetype.night-film': 'Cinema nocturn',
'studio.templates.archetype.field-notes': 'Quadern de camp',
'studio.templates.name.spineBandStitched': 'Banda cosida',
'studio.templates.name.spineBandGingham': 'Banda de quadres',
'studio.templates.name.spineBandScript': 'Banda i cal·ligrafia',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Quadrícula destacada',
'studio.templates.name.gridColumns': 'Quadrícula en columnes',
'studio.templates.name.gridMosaic': 'Quadrícula mosaic',
'studio.templates.name.spineBandPlate': 'Làmina enganxada',
'studio.templates.name.posterPopSunDisc': 'Disc solar',
'studio.templates.name.cutoutCircle': 'Cercle retallat',
'studio.templates.name.postcardGreetings': 'Postal de salutacions',
'studio.templates.name.gridBadgeTiles': 'Graella d’insígnies',
'studio.templates.name.minimalSerifCameo': 'Camafeu oval',
'studio.templates.name.stampCollageAirmail': 'Sobre per avió',
'studio.templates.name.risoOverprint': 'Cartell sobreimprès',
'studio.templates.name.risoHalftoneSun': 'Sol en trama',
'studio.templates.name.risoZineStack': 'Pila de fanzins',
'studio.templates.name.risoPinkPress': 'Premsa rosa',
'studio.templates.name.editorialMasthead': 'Portada amb capçalera',
'studio.templates.name.editorialCoverStory': 'Tema de portada',
'studio.templates.name.editorialSplit': 'Foto sobre el titular',
'studio.templates.name.editorialHeadline': 'Portada de titular',
'studio.templates.name.vintagePosterPanorama': 'Panorama de cartell',
'studio.templates.name.vintagePosterArch': 'Arc i mar',
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre turons',
'studio.templates.name.vintagePosterPeaks': 'Cims esglaonats',
'studio.templates.name.nightFilmLetterbox': 'Portada panoràmica',
'studio.templates.name.nightFilmStrip': 'Portada amb tira de pel·lícula',
'studio.templates.name.nightFilmTitleCard': 'Cartó de títol',
'studio.templates.name.nightFilmFadeOut': 'Fos a negre',
'studio.templates.name.fieldNotesTorn': 'Full de llibreta esquinçat',
'studio.templates.name.fieldNotesPolaroid': 'Polaroid amb cinta',
'studio.templates.name.fieldNotesSpecimens': 'Mostres amb cinta',
'studio.templates.name.fieldNotesLabel': 'Etiqueta de llibreta',
'studio.templates.name.spineBandClothStory': 'Relat sobre tela',
'studio.templates.name.spineBandGinghamTrio': 'Trio amb llom de quadres',
'studio.templates.name.spineBandAlbum': 'Àlbum enquadernat',
'studio.templates.name.posterPopHeadline': 'Titular de cartell',
'studio.templates.name.posterPopPostcards': 'Còpies inclinades',
'studio.templates.name.posterPopPhotoWall': 'Mur de fotos',
'studio.templates.name.cutoutStoryPage': 'Relat retallat',
'studio.templates.name.cutoutTrio': 'Trio retallat',
'studio.templates.name.cutoutScatter': 'Còpies escampades',
'studio.templates.name.cutoutMosaic': 'Mosaic retallat',
'studio.templates.name.postcardAirmail': 'Carta per avió',
'studio.templates.name.postcardRack': 'Expositor de postals',
'studio.templates.name.gridBadgeLetter': 'Segell i relat',
'studio.templates.name.gridBadgeHeroSix': 'Foto gran i sis',
'studio.templates.name.minimalSerifEssay': 'Assaig serè',
'studio.templates.name.minimalSerifTriptych': 'Tres làmines',
'studio.templates.name.stampCollageLetter': 'Carta mata-segellada',
'studio.templates.name.stampCollageArchStamps': 'Arc i segells',
'studio.templates.name.stampCollageSheet': 'Full de segells',
'studio.templates.name.risoStoryPlate': 'Relat i làmina',
'studio.templates.name.risoOffsetPair': 'Parella desplaçada',
'studio.templates.name.risoBlobTrio': 'Trio de taques',
'studio.templates.name.risoZineGrid': 'Graella de fanzín',
'studio.templates.name.risoPrintScatter': 'Còpies disperses',
'studio.templates.name.editorialFeatureOpener': 'Obertura de reportatge',
'studio.templates.name.editorialPullQuote': 'Cita destacada',
'studio.templates.name.editorialFeatureColumns': 'Reportatge en columnes',
'studio.templates.name.editorialPortfolio': 'Doble pàgina de portafoli',
'studio.templates.name.editorialContactSheet': 'Full de contactes',
'studio.templates.name.vintagePosterStory': 'Cartell i relat',
'studio.templates.name.vintagePosterTwin': 'Cartells bessons',
'studio.templates.name.vintagePosterArcade': 'Arcada d’arcs',
'studio.templates.name.vintagePosterPanels': 'Plafons i turons',
'studio.templates.name.vintagePosterLabels': 'Etiquetes d’equipatge',
'studio.templates.name.nightFilmOpening': 'Escena inicial',
'studio.templates.name.nightFilmSubtitles': 'Subtítols de pel·lícula',
'studio.templates.name.nightFilmReels': 'Fos i bobines',
'studio.templates.name.nightFilmContact': 'Full de contactes de cinema',
'studio.templates.name.nightFilmDoubleReel': 'Bobina doble',
'studio.templates.name.fieldNotesDiary': 'Pàgina de diari',
'studio.templates.name.fieldNotesTrail': 'Rastre de fitxes',
'studio.templates.name.fieldNotesRoute': 'Ruta i polaroids',
'studio.templates.name.fieldNotesContact': 'Full de contactes numerat',
'studio.templates.name.fieldNotesHerbarium': 'Plecs d’herbari',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Nòrdic',
'studio.theme.name.alpine': 'Alpí',
'studio.theme.name.indiaStamp': 'Collage de segells',
'studio.theme.name.riso': 'Impressió riso',
'studio.theme.name.magazine': 'Revista',
'studio.theme.name.retroTravel': 'Viatge retro',
'studio.theme.name.midnight': 'Mitjanit',
'studio.theme.name.fieldNotes': 'Quadern de camp',
'studio.theme.descr.posterPop': 'Paper crema, corall i gira-sol, títols de pòster gruixuts',
'studio.theme.descr.cutout': 'Paper cobalt, formes corall i sorra, lletra blanca',
'studio.theme.descr.postcard': 'Paper menta, tinta blau marí, vermell de segell i un subtítol manuscrit',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Paper gris clar amb pissarra, ocre i rosa',
'studio.theme.descr.alpine': 'Blanc de neu amb verd bosc, rovell i blau cel',
'studio.theme.descr.indiaStamp': 'Paper càlid amb magenta, safrà i verd blavós',
'studio.theme.descr.riso': 'Paper crema, blau riso i rosa fluor sobreimpresos',
'studio.theme.descr.magazine': 'Paper blanc trencat, tinta negra, un vermell i una gran capçalera',
'studio.theme.descr.retroTravel': 'Paper crema envellit amb taronja, verd blavós i mostassa',
'studio.theme.descr.midnight': 'Paper gairebé negre, lletra blanca càlida i tocs de neó',
'studio.theme.descr.fieldNotes': 'Paper kraft, verd molsa i rovell, lletra a mà i cinta',
'studio.theme.panel': 'Tema',
'studio.theme.intro':
'Tria un estil per a tot el llibre. Els colors i la lletra que segueixen el tema canvien amb ell, tota la resta es queda com està.',
-8
View File
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
'assistant.connect.quota': 'Dnes využito {used} z {count} zpráv',
'assistant.connect.quota.one': 'Dnes využito {used} z {count} zprávy',
'assistant.connect.quota.few': 'Dnes využito {used} z {count} zpráv',
'assistant.connect.claudeLink': 'Používáš Claude? Připoj se v Claude',
'assistant.privacy.what': 'Co se sdílí?',
'assistant.privacy.banner': 'Tvoje zprávy a data cesty, která asistent čte, jdou do {provider}.',
'assistant.privacy.ok': 'Rozumím',
@@ -257,13 +256,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays.one': '{count} den',
'assistant.admin.retentionDays.few': '{count} dny',
'assistant.admin.retentionForever': 'Navždy',
'assistant.claude.title': 'Připojit přes Claude',
'assistant.claude.description': 'Používej TREK přímo v Claude: přidej ho jako konektor, přihlas se a hotovo.',
'assistant.claude.open': 'Otevřít v Claude',
'assistant.claude.scan': 'Nebo ho naskenuj telefonem',
'assistant.claude.copyUrl': 'Kopírovat adresu',
'assistant.claude.qrAlt': 'QR kód pro přidání TREK do Claude',
'assistant.claude.notPublic': 'Claude se k TREK dostane, jen když je dostupný přes https na internetu.',
'assistant.mobile.tile': 'Zeptej se TREK',
'assistant.mobile.tileHint': 'Plánuj s asistentem',
'assistant.mobile.backToChat': 'Zpět do chatu',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Mřížka a odznak',
'studio.templates.archetype.minimal-serif': 'Minimalismus',
'studio.templates.archetype.stamp-collage': 'Známky',
'studio.templates.archetype.riso': 'Riso', // same-as-en
'studio.templates.archetype.editorial': 'Časopis',
'studio.templates.archetype.vintage-poster': 'Retro plakát',
'studio.templates.archetype.night-film': 'Noční film',
'studio.templates.archetype.field-notes': 'Terénní zápisky',
'studio.templates.name.spineBandStitched': 'Prošitý pruh',
'studio.templates.name.spineBandGingham': 'Kostkovaný pruh',
'studio.templates.name.spineBandScript': 'Pruh s psacím písmem',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Mřížka s hlavní fotkou',
'studio.templates.name.gridColumns': 'Sloupcová mřížka',
'studio.templates.name.gridMosaic': 'Mozaiková mřížka',
'studio.templates.name.spineBandPlate': 'Vlepená tabule',
'studio.templates.name.posterPopSunDisc': 'Sluneční kotouč',
'studio.templates.name.cutoutCircle': 'Vystřižený kruh',
'studio.templates.name.postcardGreetings': 'Pozdrav z cest',
'studio.templates.name.gridBadgeTiles': 'Mřížka odznaků',
'studio.templates.name.minimalSerifCameo': 'Oválná kamej',
'studio.templates.name.stampCollageAirmail': 'Obálka letecké pošty',
'studio.templates.name.risoOverprint': 'Plakát s přetiskem',
'studio.templates.name.risoHalftoneSun': 'Rastrové slunce',
'studio.templates.name.risoZineStack': 'Hromádka zinů',
'studio.templates.name.risoPinkPress': 'Růžový tisk',
'studio.templates.name.editorialMasthead': 'Obálka s hlavičkou',
'studio.templates.name.editorialCoverStory': 'Titulní příběh',
'studio.templates.name.editorialSplit': 'Fotka nad titulkem',
'studio.templates.name.editorialHeadline': 'Obálka s titulkem',
'studio.templates.name.vintagePosterPanorama': 'Plakátové panorama',
'studio.templates.name.vintagePosterArch': 'Oblouk a moře',
'studio.templates.name.vintagePosterSunDisc': 'Slunce nad kopci',
'studio.templates.name.vintagePosterPeaks': 'Vrstvené štíty',
'studio.templates.name.nightFilmLetterbox': 'Širokoúhlá obálka',
'studio.templates.name.nightFilmStrip': 'Obálka s filmovým pásem',
'studio.templates.name.nightFilmTitleCard': 'Titulková karta',
'studio.templates.name.nightFilmFadeOut': 'Stmívačka',
'studio.templates.name.fieldNotesTorn': 'Vytržený list zápisníku',
'studio.templates.name.fieldNotesPolaroid': 'Polaroid na pásce',
'studio.templates.name.fieldNotesSpecimens': 'Vzorky na pásce',
'studio.templates.name.fieldNotesLabel': 'Štítek zápisníku',
'studio.templates.name.spineBandClothStory': 'Příběh na plátně',
'studio.templates.name.spineBandGinghamTrio': 'Trojice s kostkovaným hřbetem',
'studio.templates.name.spineBandAlbum': 'Vázané album',
'studio.templates.name.posterPopHeadline': 'Plakátový titulek',
'studio.templates.name.posterPopPostcards': 'Šikmé fotky',
'studio.templates.name.posterPopPhotoWall': 'Stěna fotek',
'studio.templates.name.cutoutStoryPage': 'Vystřižený příběh',
'studio.templates.name.cutoutTrio': 'Vystřižená trojice',
'studio.templates.name.cutoutScatter': 'Rozházené fotky',
'studio.templates.name.cutoutMosaic': 'Vystřižená mozaika',
'studio.templates.name.postcardAirmail': 'Dopis leteckou poštou',
'studio.templates.name.postcardRack': 'Stojan s pohlednicemi',
'studio.templates.name.gridBadgeLetter': 'Pečeť a příběh',
'studio.templates.name.gridBadgeHeroSix': 'Hlavní fotka a šest',
'studio.templates.name.minimalSerifEssay': 'Tichá esej',
'studio.templates.name.minimalSerifTriptych': 'Tři tabule',
'studio.templates.name.stampCollageLetter': 'Orazítkovaný dopis',
'studio.templates.name.stampCollageArchStamps': 'Oblouk a známky',
'studio.templates.name.stampCollageSheet': 'Arch známek',
'studio.templates.name.risoStoryPlate': 'Příběh a tabule',
'studio.templates.name.risoOffsetPair': 'Posunutá dvojice',
'studio.templates.name.risoBlobTrio': 'Trojice skvrn',
'studio.templates.name.risoZineGrid': 'Mřížka zinu',
'studio.templates.name.risoPrintScatter': 'Rozptýlené tisky',
'studio.templates.name.editorialFeatureOpener': 'Úvod reportáže',
'studio.templates.name.editorialPullQuote': 'Zvýrazněný citát',
'studio.templates.name.editorialFeatureColumns': 'Reportáž ve sloupcích',
'studio.templates.name.editorialPortfolio': 'Dvoustrana portfolia',
'studio.templates.name.editorialContactSheet': 'Kontaktní arch',
'studio.templates.name.vintagePosterStory': 'Plakát a příběh',
'studio.templates.name.vintagePosterTwin': 'Dvojčata plakátů',
'studio.templates.name.vintagePosterArcade': 'Arkáda oblouků',
'studio.templates.name.vintagePosterPanels': 'Panely a kopce',
'studio.templates.name.vintagePosterLabels': 'Zavazadlové štítky',
'studio.templates.name.nightFilmOpening': 'Úvodní scéna',
'studio.templates.name.nightFilmSubtitles': 'Filmové titulky',
'studio.templates.name.nightFilmReels': 'Stmívačka a cívky',
'studio.templates.name.nightFilmContact': 'Filmový kontaktní arch',
'studio.templates.name.nightFilmDoubleReel': 'Dvojitá cívka',
'studio.templates.name.fieldNotesDiary': 'Stránka deníku',
'studio.templates.name.fieldNotesTrail': 'Stopa kartiček',
'studio.templates.name.fieldNotesRoute': 'Trasa a polaroidy',
'studio.templates.name.fieldNotesContact': 'Číslovaný kontaktní arch',
'studio.templates.name.fieldNotesHerbarium': 'Herbářové archy',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Severský',
'studio.theme.name.alpine': 'Alpský',
'studio.theme.name.indiaStamp': 'Koláž známek',
'studio.theme.name.riso': 'Risotisk',
'studio.theme.name.magazine': 'Časopis',
'studio.theme.name.retroTravel': 'Retro cestování',
'studio.theme.name.midnight': 'Půlnoc',
'studio.theme.name.fieldNotes': 'Terénní zápisky',
'studio.theme.descr.posterPop': 'Krémový papír, korálová a slunečnicová, výrazné plakátové nadpisy',
'studio.theme.descr.cutout': 'Kobaltový papír, tvary v korálové a písková, bílé písmo',
'studio.theme.descr.postcard': 'Mátový papír, námořnická modř, razítková červená a psaný podtitul',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Světle šedý papír s břidlicovou, okrovou a růžovou',
'studio.theme.descr.alpine': 'Sněhově bílá s lesní zelenou, rezavou a nebeskou modří',
'studio.theme.descr.indiaStamp': 'Teplý papír s purpurovou, šafránovou a petrolejovou',
'studio.theme.descr.riso': 'Krémový papír, modrá riso a fluo růžová přes sebe',
'studio.theme.descr.magazine': 'Lomená bílá, černá barva, jedna červená a velké záhlaví',
'studio.theme.descr.retroTravel': 'Zažloutlý krémový papír s oranžovou, petrolejovou a hořčicovou',
'studio.theme.descr.midnight': 'Téměř černý papír, teple bílé písmo a neonové akcenty',
'studio.theme.descr.fieldNotes': 'Balicí papír, mechová zeleň a rez, rukopis a páska',
'studio.theme.panel': 'Motiv',
'studio.theme.intro':
'Vyberte vzhled pro celou knihu. Barvy a písmo, které motiv následují, se změní s ním, vše ostatní zůstane, jak je.',
-8
View File
@@ -67,7 +67,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'Nicht erreichbar',
'assistant.connect.quota': 'Heute {used} von {count} Nachrichten genutzt',
'assistant.connect.quota.one': 'Heute {used} von {count} Nachricht genutzt',
'assistant.connect.claudeLink': 'Du nutzt Claude? In Claude verbinden',
'assistant.privacy.what': 'Was wird geteilt?',
'assistant.privacy.banner': 'Deine Nachrichten und die Trip-Daten, die der Assistent liest, gehen an {provider}.',
'assistant.privacy.ok': 'Verstanden',
@@ -253,13 +252,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} Tage',
'assistant.admin.retentionDays.one': '{count} Tag',
'assistant.admin.retentionForever': 'Für immer',
'assistant.claude.title': 'Mit Claude verbinden',
'assistant.claude.description': 'Nutze TREK direkt in Claude: als Connector hinzufügen, anmelden, fertig.',
'assistant.claude.open': 'In Claude öffnen',
'assistant.claude.scan': 'Oder mit dem Handy scannen',
'assistant.claude.copyUrl': 'Adresse kopieren',
'assistant.claude.qrAlt': 'QR-Code, um TREK in Claude hinzuzufügen',
'assistant.claude.notPublic': 'Claude erreicht TREK nur, wenn es per https aus dem Internet erreichbar ist.',
'assistant.mobile.tile': 'Frag TREK',
'assistant.mobile.tileHint': 'Plane mit dem Assistenten',
'assistant.mobile.backToChat': 'Zurück zum Chat',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Raster mit Plakette',
'studio.templates.archetype.minimal-serif': 'Minimal', // same-as-en
'studio.templates.archetype.stamp-collage': 'Briefmarken',
'studio.templates.archetype.riso': 'Riso', // same-as-en
'studio.templates.archetype.editorial': 'Magazin',
'studio.templates.archetype.vintage-poster': 'Retroplakat',
'studio.templates.archetype.night-film': 'Nachtfilm',
'studio.templates.archetype.field-notes': 'Feldnotizen',
'studio.templates.name.spineBandStitched': 'Genähtes Band',
'studio.templates.name.spineBandGingham': 'Band mit Vichykaro',
'studio.templates.name.spineBandScript': 'Band mit Schreibschrift',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Raster mit Hauptbild',
'studio.templates.name.gridColumns': 'Spaltenraster',
'studio.templates.name.gridMosaic': 'Mosaikraster',
'studio.templates.name.spineBandPlate': 'Eingeklebte Bildtafel',
'studio.templates.name.posterPopSunDisc': 'Sonnenscheibe',
'studio.templates.name.cutoutCircle': 'Kreis im Scherenschnitt',
'studio.templates.name.postcardGreetings': 'Grußpostkarte',
'studio.templates.name.gridBadgeTiles': 'Raster mit Plaketten',
'studio.templates.name.minimalSerifCameo': 'Ovale Kamee',
'studio.templates.name.stampCollageAirmail': 'Luftpostumschlag',
'studio.templates.name.risoOverprint': 'Überdruck-Plakat',
'studio.templates.name.risoHalftoneSun': 'Rastersonne',
'studio.templates.name.risoZineStack': 'Zine-Stapel',
'studio.templates.name.risoPinkPress': 'Pinker Druck',
'studio.templates.name.editorialMasthead': 'Titelkopf',
'studio.templates.name.editorialCoverStory': 'Titelgeschichte',
'studio.templates.name.editorialSplit': 'Foto über Schlagzeile',
'studio.templates.name.editorialHeadline': 'Schlagzeilen-Titel',
'studio.templates.name.vintagePosterPanorama': 'Plakatpanorama',
'studio.templates.name.vintagePosterArch': 'Bogen und Meer',
'studio.templates.name.vintagePosterSunDisc': 'Sonne über Hügeln',
'studio.templates.name.vintagePosterPeaks': 'Gestaffelte Gipfel',
'studio.templates.name.nightFilmLetterbox': 'Breitbild-Titel',
'studio.templates.name.nightFilmStrip': 'Filmstreifen-Titel',
'studio.templates.name.nightFilmTitleCard': 'Titelkarte',
'studio.templates.name.nightFilmFadeOut': 'Abblende',
'studio.templates.name.fieldNotesTorn': 'Abgerissenes Notizblatt',
'studio.templates.name.fieldNotesPolaroid': 'Polaroid mit Klebeband',
'studio.templates.name.fieldNotesSpecimens': 'Eingeklebte Fundstücke',
'studio.templates.name.fieldNotesLabel': 'Notizbuch-Etikett',
'studio.templates.name.spineBandClothStory': 'Geschichte auf Leinen',
'studio.templates.name.spineBandGinghamTrio': 'Vichy-Rücken mit drei',
'studio.templates.name.spineBandAlbum': 'Gebundenes Album',
'studio.templates.name.posterPopHeadline': 'Plakat-Schlagzeile',
'studio.templates.name.posterPopPostcards': 'Schräge Abzüge',
'studio.templates.name.posterPopPhotoWall': 'Fotowand',
'studio.templates.name.cutoutStoryPage': 'Geschichte im Scherenschnitt',
'studio.templates.name.cutoutTrio': 'Scherenschnitt mit drei',
'studio.templates.name.cutoutScatter': 'Verstreute Abzüge',
'studio.templates.name.cutoutMosaic': 'Scherenschnitt-Mosaik',
'studio.templates.name.postcardAirmail': 'Luftpostbrief',
'studio.templates.name.postcardRack': 'Postkartenständer',
'studio.templates.name.gridBadgeLetter': 'Siegel und Geschichte',
'studio.templates.name.gridBadgeHeroSix': 'Hauptfoto und sechs',
'studio.templates.name.minimalSerifEssay': 'Stiller Essay',
'studio.templates.name.minimalSerifTriptych': 'Drei Bildtafeln',
'studio.templates.name.stampCollageLetter': 'Abgestempelter Brief',
'studio.templates.name.stampCollageArchStamps': 'Bogen und Briefmarken',
'studio.templates.name.stampCollageSheet': 'Briefmarkenbogen',
'studio.templates.name.risoStoryPlate': 'Geschichte mit Bild',
'studio.templates.name.risoOffsetPair': 'Versetztes Paar',
'studio.templates.name.risoBlobTrio': 'Drei Kleckse',
'studio.templates.name.risoZineGrid': 'Zine-Raster',
'studio.templates.name.risoPrintScatter': 'Verstreute Drucke',
'studio.templates.name.editorialFeatureOpener': 'Reportage-Auftakt',
'studio.templates.name.editorialPullQuote': 'Herausgestelltes Zitat',
'studio.templates.name.editorialFeatureColumns': 'Reportage in Spalten',
'studio.templates.name.editorialPortfolio': 'Portfolio-Doppelseite',
'studio.templates.name.editorialContactSheet': 'Kontaktabzug',
'studio.templates.name.vintagePosterStory': 'Plakat und Geschichte',
'studio.templates.name.vintagePosterTwin': 'Plakatpaar',
'studio.templates.name.vintagePosterArcade': 'Bogengang',
'studio.templates.name.vintagePosterPanels': 'Tafeln und Hügel',
'studio.templates.name.vintagePosterLabels': 'Kofferanhänger',
'studio.templates.name.nightFilmOpening': 'Eröffnungsszene',
'studio.templates.name.nightFilmSubtitles': 'Film mit Untertiteln',
'studio.templates.name.nightFilmReels': 'Abblende und Rollen',
'studio.templates.name.nightFilmContact': 'Film-Kontaktabzug',
'studio.templates.name.nightFilmDoubleReel': 'Doppelte Filmrolle',
'studio.templates.name.fieldNotesDiary': 'Tagebuchseite',
'studio.templates.name.fieldNotesTrail': 'Spur aus Notizkarten',
'studio.templates.name.fieldNotesRoute': 'Route und Polaroids',
'studio.templates.name.fieldNotesContact': 'Nummerierter Kontaktabzug',
'studio.templates.name.fieldNotesHerbarium': 'Herbarbögen',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Nordisch',
'studio.theme.name.alpine': 'Alpin',
'studio.theme.name.indiaStamp': 'Briefmarken-Collage',
'studio.theme.name.riso': 'Risodruck',
'studio.theme.name.magazine': 'Magazin',
'studio.theme.name.retroTravel': 'Retroreise',
'studio.theme.name.midnight': 'Mitternacht',
'studio.theme.name.fieldNotes': 'Feldnotizen',
'studio.theme.descr.posterPop': 'Cremepapier, Koralle und Sonnengelb, kräftige Plakattitel',
'studio.theme.descr.cutout': 'Kobaltpapier, Formen in Koralle und Sand, weiße Schrift',
'studio.theme.descr.postcard': 'Mintpapier, Marineblau, Stempelrot und ein Untertitel in Schreibschrift',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Hellgraues Papier mit Schiefer, Ocker und Rosé',
'studio.theme.descr.alpine': 'Schneeweiß mit Waldgrün, Rost und Himmelblau',
'studio.theme.descr.indiaStamp': 'Warmes Papier mit Magenta, Safran und Petrol',
'studio.theme.descr.riso': 'Cremepapier, Risoblau und Neonpink übereinander gedruckt',
'studio.theme.descr.magazine': 'Gebrochenes Weiß, schwarze Schrift, ein Rot und ein großer Titelkopf',
'studio.theme.descr.retroTravel': 'Vergilbtes Cremepapier mit Orange, Petrol und Senfgelb',
'studio.theme.descr.midnight': 'Fast schwarzes Papier, warmweiße Schrift und Neonakzente',
'studio.theme.descr.fieldNotes': 'Packpapier, Moosgrün und Rost, Handschrift und Klebeband',
'studio.theme.panel': 'Thema',
'studio.theme.intro':
'Wähle einen Look für das ganze Buch. Farben und Schrift, die dem Thema folgen, ändern sich mit, alles andere bleibt, wie es ist.',
-8
View File
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'Not reachable',
'assistant.connect.quota': '{used} of {count} messages used today',
'assistant.connect.quota.one': '{used} of {count} message used today',
'assistant.connect.claudeLink': 'Using Claude? Connect in Claude',
'assistant.privacy.what': 'What gets shared?',
'assistant.privacy.banner': 'Your messages and the trip data the assistant reads go to {provider}.',
'assistant.privacy.ok': 'Got it',
@@ -249,13 +248,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} days',
'assistant.admin.retentionDays.one': '{count} day',
'assistant.admin.retentionForever': 'Forever',
'assistant.claude.title': 'Connect with Claude',
'assistant.claude.description': 'Use TREK right inside Claude: add it as a connector, sign in, done.',
'assistant.claude.open': 'Open in Claude',
'assistant.claude.scan': 'Or scan it with your phone',
'assistant.claude.copyUrl': 'Copy address',
'assistant.claude.qrAlt': 'QR code to add TREK in Claude',
'assistant.claude.notPublic': 'Claude can only reach TREK when it is available over https on the internet.',
'assistant.mobile.tile': 'Ask TREK',
'assistant.mobile.tileHint': 'Plan with the assistant',
'assistant.mobile.backToChat': 'Back to chat',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Grid and badge',
'studio.templates.archetype.minimal-serif': 'Minimal',
'studio.templates.archetype.stamp-collage': 'Stamps',
'studio.templates.archetype.riso': 'Riso',
'studio.templates.archetype.editorial': 'Editorial',
'studio.templates.archetype.vintage-poster': 'Vintage poster',
'studio.templates.archetype.night-film': 'Night film',
'studio.templates.archetype.field-notes': 'Field notes',
'studio.templates.name.spineBandStitched': 'Stitched band',
'studio.templates.name.spineBandGingham': 'Gingham band',
'studio.templates.name.spineBandScript': 'Band with script',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Feature grid',
'studio.templates.name.gridColumns': 'Column grid',
'studio.templates.name.gridMosaic': 'Mosaic grid',
'studio.templates.name.spineBandPlate': 'Tipped-in plate',
'studio.templates.name.posterPopSunDisc': 'Sun disc',
'studio.templates.name.cutoutCircle': 'Circle cut-out',
'studio.templates.name.postcardGreetings': 'Greetings postcard',
'studio.templates.name.gridBadgeTiles': 'Badge tile grid',
'studio.templates.name.minimalSerifCameo': 'Oval cameo',
'studio.templates.name.stampCollageAirmail': 'Airmail envelope',
'studio.templates.name.risoOverprint': 'Overprint poster',
'studio.templates.name.risoHalftoneSun': 'Halftone sun',
'studio.templates.name.risoZineStack': 'Zine stack',
'studio.templates.name.risoPinkPress': 'Pink press',
'studio.templates.name.editorialMasthead': 'Masthead cover',
'studio.templates.name.editorialCoverStory': 'Cover story',
'studio.templates.name.editorialSplit': 'Photo over headline',
'studio.templates.name.editorialHeadline': 'Headline cover',
'studio.templates.name.vintagePosterPanorama': 'Poster panorama',
'studio.templates.name.vintagePosterArch': 'Arch and sea',
'studio.templates.name.vintagePosterSunDisc': 'Sun over hills',
'studio.templates.name.vintagePosterPeaks': 'Stacked peaks',
'studio.templates.name.nightFilmLetterbox': 'Letterbox cover',
'studio.templates.name.nightFilmStrip': 'Film strip cover',
'studio.templates.name.nightFilmTitleCard': 'Title card',
'studio.templates.name.nightFilmFadeOut': 'Fade to black',
'studio.templates.name.fieldNotesTorn': 'Torn notebook sheet',
'studio.templates.name.fieldNotesPolaroid': 'Taped polaroid',
'studio.templates.name.fieldNotesSpecimens': 'Taped specimens',
'studio.templates.name.fieldNotesLabel': 'Notebook label',
'studio.templates.name.spineBandClothStory': 'Story on cloth',
'studio.templates.name.spineBandGinghamTrio': 'Gingham spine trio',
'studio.templates.name.spineBandAlbum': 'Bound album',
'studio.templates.name.posterPopHeadline': 'Poster headline',
'studio.templates.name.posterPopPostcards': 'Tilted prints',
'studio.templates.name.posterPopPhotoWall': 'Photo wall',
'studio.templates.name.cutoutStoryPage': 'Cut-out story',
'studio.templates.name.cutoutTrio': 'Cut-out trio',
'studio.templates.name.cutoutScatter': 'Scattered prints',
'studio.templates.name.cutoutMosaic': 'Cut-out mosaic',
'studio.templates.name.postcardAirmail': 'Airmail letter',
'studio.templates.name.postcardRack': 'Postcard rack',
'studio.templates.name.gridBadgeLetter': 'Seal and story',
'studio.templates.name.gridBadgeHeroSix': 'Hero and six',
'studio.templates.name.minimalSerifEssay': 'Quiet essay',
'studio.templates.name.minimalSerifTriptych': 'Three plates',
'studio.templates.name.stampCollageLetter': 'Postmarked letter',
'studio.templates.name.stampCollageArchStamps': 'Arch and stamps',
'studio.templates.name.stampCollageSheet': 'Stamp sheet',
'studio.templates.name.risoStoryPlate': 'Story plate',
'studio.templates.name.risoOffsetPair': 'Offset pair',
'studio.templates.name.risoBlobTrio': 'Blob trio',
'studio.templates.name.risoZineGrid': 'Zine grid',
'studio.templates.name.risoPrintScatter': 'Print scatter',
'studio.templates.name.editorialFeatureOpener': 'Feature opener',
'studio.templates.name.editorialPullQuote': 'Pull quote',
'studio.templates.name.editorialFeatureColumns': 'Feature columns',
'studio.templates.name.editorialPortfolio': 'Portfolio spread',
'studio.templates.name.editorialContactSheet': 'Contact sheet',
'studio.templates.name.vintagePosterStory': 'Poster and story',
'studio.templates.name.vintagePosterTwin': 'Twin posters',
'studio.templates.name.vintagePosterArcade': 'Arch arcade',
'studio.templates.name.vintagePosterPanels': 'Panels and hills',
'studio.templates.name.vintagePosterLabels': 'Luggage labels',
'studio.templates.name.nightFilmOpening': 'Opening scene',
'studio.templates.name.nightFilmSubtitles': 'Film subtitles',
'studio.templates.name.nightFilmReels': 'Fade and reels',
'studio.templates.name.nightFilmContact': 'Film contact sheet',
'studio.templates.name.nightFilmDoubleReel': 'Double reel',
'studio.templates.name.fieldNotesDiary': 'Diary page',
'studio.templates.name.fieldNotesTrail': 'Note card trail',
'studio.templates.name.fieldNotesRoute': 'Route and polaroids',
'studio.templates.name.fieldNotesContact': 'Numbered contact sheet',
'studio.templates.name.fieldNotesHerbarium': 'Herbarium sheets',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Nordic',
'studio.theme.name.alpine': 'Alpine',
'studio.theme.name.indiaStamp': 'Stamp collage',
'studio.theme.name.riso': 'Riso print',
'studio.theme.name.magazine': 'Magazine',
'studio.theme.name.retroTravel': 'Retro travel',
'studio.theme.name.midnight': 'Midnight',
'studio.theme.name.fieldNotes': 'Field notes',
'studio.theme.descr.posterPop': 'Cream paper, coral and sunflower, heavy poster titles',
'studio.theme.descr.cutout': 'Cobalt paper, coral and sand shapes, white type',
'studio.theme.descr.postcard': 'Mint paper, navy ink, stamp red and a script subtitle',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Pale grey paper with slate, ochre and rose',
'studio.theme.descr.alpine': 'Snow white with forest green, rust and sky blue',
'studio.theme.descr.indiaStamp': 'Warm paper with magenta, saffron and teal',
'studio.theme.descr.riso': 'Cream paper, riso blue and fluo pink overprinted',
'studio.theme.descr.magazine': 'Off white paper, black ink, one red and a big masthead',
'studio.theme.descr.retroTravel': 'Aged cream paper with orange, teal and mustard',
'studio.theme.descr.midnight': 'Near black paper, warm white type and neon accents',
'studio.theme.descr.fieldNotes': 'Kraft paper, moss green and rust, handwriting and tape',
'studio.theme.panel': 'Theme',
'studio.theme.intro':
'Pick a look for the whole book. Colours and type that follow the theme change with it, everything else stays as it is.',
-8
View File
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
'assistant.connect.statusError': 'No disponible',
'assistant.connect.quota': '{used} de {count} mensajes usados hoy',
'assistant.connect.quota.one': '{used} de {count} mensaje usado hoy',
'assistant.connect.claudeLink': '¿Usas Claude? Conéctate en Claude',
'assistant.privacy.what': '¿Qué se comparte?',
'assistant.privacy.banner': 'Tus mensajes y los datos del viaje que lee el asistente se envían a {provider}.',
'assistant.privacy.ok': 'Entendido',
@@ -252,13 +251,6 @@ const assistant: TranslationStrings = {
'assistant.admin.retentionDays': '{count} días',
'assistant.admin.retentionDays.one': '{count} día',
'assistant.admin.retentionForever': 'Para siempre',
'assistant.claude.title': 'Conectar con Claude',
'assistant.claude.description': 'Usa TREK directamente en Claude: añádelo como conector, inicia sesión y listo.',
'assistant.claude.open': 'Abrir en Claude',
'assistant.claude.scan': 'O escanéalo con tu móvil',
'assistant.claude.copyUrl': 'Copiar dirección',
'assistant.claude.qrAlt': 'Código QR para añadir TREK en Claude',
'assistant.claude.notPublic': 'Claude solo puede acceder a TREK si está disponible por https en internet.',
'assistant.mobile.tile': 'Pregunta a TREK',
'assistant.mobile.tileHint': 'Planifica con el asistente',
'assistant.mobile.backToChat': 'Volver al chat',
+76
View File
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
'studio.templates.archetype.grid-badge': 'Cuadrícula e insignia',
'studio.templates.archetype.minimal-serif': 'Minimalista',
'studio.templates.archetype.stamp-collage': 'Sellos',
'studio.templates.archetype.riso': 'Riso', // same-as-en
'studio.templates.archetype.editorial': 'Revista',
'studio.templates.archetype.vintage-poster': 'Cartel antiguo',
'studio.templates.archetype.night-film': 'Cine nocturno',
'studio.templates.archetype.field-notes': 'Cuaderno de campo',
'studio.templates.name.spineBandStitched': 'Banda cosida',
'studio.templates.name.spineBandGingham': 'Banda de vichy',
'studio.templates.name.spineBandScript': 'Banda y caligrafía',
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
'studio.templates.name.gridFeature': 'Cuadrícula destacada',
'studio.templates.name.gridColumns': 'Cuadrícula en columnas',
'studio.templates.name.gridMosaic': 'Cuadrícula mosaico',
'studio.templates.name.spineBandPlate': 'Lámina pegada',
'studio.templates.name.posterPopSunDisc': 'Disco solar',
'studio.templates.name.cutoutCircle': 'Círculo recortado',
'studio.templates.name.postcardGreetings': 'Postal de saludos',
'studio.templates.name.gridBadgeTiles': 'Cuadrícula de insignias',
'studio.templates.name.minimalSerifCameo': 'Camafeo ovalado',
'studio.templates.name.stampCollageAirmail': 'Sobre de correo aéreo',
'studio.templates.name.risoOverprint': 'Póster sobreimpreso',
'studio.templates.name.risoHalftoneSun': 'Sol en trama',
'studio.templates.name.risoZineStack': 'Pila de fanzines',
'studio.templates.name.risoPinkPress': 'Prensa rosa',
'studio.templates.name.editorialMasthead': 'Portada con cabecera',
'studio.templates.name.editorialCoverStory': 'Tema de portada',
'studio.templates.name.editorialSplit': 'Foto sobre titular',
'studio.templates.name.editorialHeadline': 'Portada de titular',
'studio.templates.name.vintagePosterPanorama': 'Panorama de póster',
'studio.templates.name.vintagePosterArch': 'Arco y mar',
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre colinas',
'studio.templates.name.vintagePosterPeaks': 'Picos escalonados',
'studio.templates.name.nightFilmLetterbox': 'Portada panorámica',
'studio.templates.name.nightFilmStrip': 'Portada con tira de película',
'studio.templates.name.nightFilmTitleCard': 'Cartón de título',
'studio.templates.name.nightFilmFadeOut': 'Fundido a negro',
'studio.templates.name.fieldNotesTorn': 'Hoja de cuaderno rota',
'studio.templates.name.fieldNotesPolaroid': 'Polaroid con cinta',
'studio.templates.name.fieldNotesSpecimens': 'Muestras con cinta',
'studio.templates.name.fieldNotesLabel': 'Etiqueta de cuaderno',
'studio.templates.name.spineBandClothStory': 'Relato sobre tela',
'studio.templates.name.spineBandGinghamTrio': 'Trío con lomo vichy',
'studio.templates.name.spineBandAlbum': 'Álbum encuadernado',
'studio.templates.name.posterPopHeadline': 'Titular de póster',
'studio.templates.name.posterPopPostcards': 'Copias inclinadas',
'studio.templates.name.posterPopPhotoWall': 'Muro de fotos',
'studio.templates.name.cutoutStoryPage': 'Relato recortado',
'studio.templates.name.cutoutTrio': 'Trío recortado',
'studio.templates.name.cutoutScatter': 'Copias dispersas',
'studio.templates.name.cutoutMosaic': 'Mosaico recortado',
'studio.templates.name.postcardAirmail': 'Carta por avión',
'studio.templates.name.postcardRack': 'Expositor de postales',
'studio.templates.name.gridBadgeLetter': 'Sello y relato',
'studio.templates.name.gridBadgeHeroSix': 'Foto principal y seis',
'studio.templates.name.minimalSerifEssay': 'Ensayo sereno',
'studio.templates.name.minimalSerifTriptych': 'Tres láminas',
'studio.templates.name.stampCollageLetter': 'Carta matasellada',
'studio.templates.name.stampCollageArchStamps': 'Arco y sellos',
'studio.templates.name.stampCollageSheet': 'Hoja de sellos',
'studio.templates.name.risoStoryPlate': 'Relato y lámina',
'studio.templates.name.risoOffsetPair': 'Pareja desplazada',
'studio.templates.name.risoBlobTrio': 'Trío de manchas',
'studio.templates.name.risoZineGrid': 'Cuadrícula de fanzine',
'studio.templates.name.risoPrintScatter': 'Copias esparcidas',
'studio.templates.name.editorialFeatureOpener': 'Apertura de reportaje',
'studio.templates.name.editorialPullQuote': 'Cita destacada',
'studio.templates.name.editorialFeatureColumns': 'Reportaje en columnas',
'studio.templates.name.editorialPortfolio': 'Doble página de portafolio',
'studio.templates.name.editorialContactSheet': 'Hoja de contactos',
'studio.templates.name.vintagePosterStory': 'Póster y relato',
'studio.templates.name.vintagePosterTwin': 'Pósteres gemelos',
'studio.templates.name.vintagePosterArcade': 'Arcada de arcos',
'studio.templates.name.vintagePosterPanels': 'Paneles y colinas',
'studio.templates.name.vintagePosterLabels': 'Etiquetas de equipaje',
'studio.templates.name.nightFilmOpening': 'Escena inicial',
'studio.templates.name.nightFilmSubtitles': 'Subtítulos de película',
'studio.templates.name.nightFilmReels': 'Fundido y bobinas',
'studio.templates.name.nightFilmContact': 'Hoja de contactos de cine',
'studio.templates.name.nightFilmDoubleReel': 'Bobina doble',
'studio.templates.name.fieldNotesDiary': 'Página de diario',
'studio.templates.name.fieldNotesTrail': 'Rastro de fichas',
'studio.templates.name.fieldNotesRoute': 'Ruta y polaroids',
'studio.templates.name.fieldNotesContact': 'Hoja de contactos numerada',
'studio.templates.name.fieldNotesHerbarium': 'Pliegos de herbario',
};
export default studioTemplates;
+10
View File
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.name.nordic': 'Nórdico',
'studio.theme.name.alpine': 'Alpino',
'studio.theme.name.indiaStamp': 'Collage de sellos',
'studio.theme.name.riso': 'Impresión riso',
'studio.theme.name.magazine': 'Revista',
'studio.theme.name.retroTravel': 'Viaje retro',
'studio.theme.name.midnight': 'Medianoche',
'studio.theme.name.fieldNotes': 'Cuaderno de campo',
'studio.theme.descr.posterPop': 'Papel crema, coral y girasol, títulos de póster gruesos',
'studio.theme.descr.cutout': 'Papel cobalto, formas coral y arena, letra blanca',
'studio.theme.descr.postcard': 'Papel menta, tinta marino, rojo de sello y un subtítulo manuscrito',
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
'studio.theme.descr.nordic': 'Papel gris claro con pizarra, ocre y rosa',
'studio.theme.descr.alpine': 'Blanco nieve con verde bosque, óxido y azul cielo',
'studio.theme.descr.indiaStamp': 'Papel cálido con magenta, azafrán y verde azulado',
'studio.theme.descr.riso': 'Papel crema, azul riso y rosa flúor sobreimpresos',
'studio.theme.descr.magazine': 'Papel blanco roto, tinta negra, un rojo y una gran cabecera',
'studio.theme.descr.retroTravel': 'Papel crema envejecido con naranja, verde azulado y mostaza',
'studio.theme.descr.midnight': 'Papel casi negro, letra blanca cálida y acentos neón',
'studio.theme.descr.fieldNotes': 'Papel kraft, verde musgo y óxido, letra a mano y cinta',
'studio.theme.panel': 'Tema',
'studio.theme.intro':
'Elige un estilo para todo el libro. Los colores y la letra que siguen al tema cambian con él, todo lo demás se queda como está.',

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