Merge pull request #2348 from liketrek/fix/collab-links-polish

feat(collab): make the links panel look like the rest of the collab tabs
This commit is contained in:
Maurice
2026-09-12 19:44:20 +02:00
committed by GitHub
4 changed files with 289 additions and 29 deletions
@@ -0,0 +1,129 @@
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabLinks from './CollabLinks';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const buildLink = (overrides: Record<string, unknown> = {}) => ({
id: 1,
title: 'Ferry timetable',
url: 'https://ferries.example/timetable',
pinned: 0,
...overrides,
});
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
});
describe('CollabLinks', () => {
it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => {
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
// The mascot carries the scene class; it is what makes this panel match its siblings.
await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument());
});
it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => {
render(<CollabLinks tripId={1} />);
const heading = await screen.findByRole('heading', { level: 3 });
expect(heading).toHaveTextContent(/links/i);
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-003: renders a link with its title and url', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
});
it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => {
const user = userEvent.setup();
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
});
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
const user = userEvent.setup();
let posted: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/links', async ({ request }) => {
posted = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) });
}),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
});
it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabLinks tripId={1} />);
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
});
it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => {
server.use(
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
);
render(<CollabLinks tripId={1} />);
expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
});
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
// collab_edit reserved for the owner, on somebody else's trip.
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) });
render(<CollabLinks tripId={1} />);
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument();
});
});
+137 -28
View File
@@ -1,12 +1,16 @@
import { FormEvent, useEffect, useState } from 'react'
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import EmptyState from '../shared/EmptyState'
import { useToast } from '../shared/Toast'
const FONT = "var(--font-system)"
interface CollabLink {
id: number
title: string
@@ -17,21 +21,91 @@ interface CollabLink {
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
}
/**
* The add form as a modal, like the poll and note panels next to it. Inline it
* pushed the list down and gave the panel a second header, which is what made
* this tab read as a different app from its four siblings.
*/
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const valid = title.trim().length > 0 && url.trim().length > 0
const submit = async (event: FormEvent) => {
event.preventDefault()
if (!valid || busy) return
setBusy(true)
try {
await onCreate({ title: title.trim(), url: url.trim() })
onClose()
} catch {
// add() already said what went wrong; the form stays open with the input in it.
} finally {
setBusy(false)
}
}
const field: CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
}
const label: CSSProperties = {
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
}
return createPortal(
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
</div>
<div>
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
</div>
<button type="submit" disabled={!valid || busy} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
}}>
{busy ? '...' : t('collab.links.save')}
</button>
</div>
</form>
</div>,
document.body,
)
}
export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const [showForm, setShowForm] = useState(false)
// Both are new objects on every render, so depending on them would reload the
// list after each keystroke and drop a link that was just added back out of it.
const toastRef = useRef(toast)
toastRef.current = toast
const tRef = useRef(t)
tRef.current = t
useEffect(() => {
let cancelled = false
@@ -40,10 +114,10 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
.catch(err => {
if (cancelled) return
console.error('Failed to load collab links:', err)
toast.error(t('common.error'))
toastRef.current.error(tRef.current('common.error'))
})
return () => { cancelled = true }
}, [tripId, toast, t])
}, [tripId])
// Live sync, the same shape the notes panel uses. Without it the three
// collab:link events the server broadcasts would arrive nowhere and a link
@@ -73,19 +147,14 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
// Every write says something when it fails. Silence left the pin looking
// unchanged and the delete looking ignored, with an unhandled rejection in
// the console as the only trace.
const add = async (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
const add = async (data: { title: string; url: string }) => {
try {
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
const d = await collabApi.createLink(tripId, data)
setLinks(v => [d.link, ...v])
setTitle(''); setUrl(''); setExpanded(false)
} catch (err) {
console.error('Failed to add collab link:', err)
toast.error(t('common.error'))
} finally {
setBusy(false)
throw err
}
}
@@ -108,16 +177,56 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
toast.error(t('common.error'))
}
}
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
</div>
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<Link2 size={14} color="var(--text-faint)" />
{t('collab.tabs.links')}
</h3>
{canEdit && (
<button type="button" onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.links.add')}
</button>
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
</a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
{canEdit && <>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
</div>
)
}
@@ -27,6 +27,7 @@ export type TrekScene =
| 'packing'
| 'files'
| 'notes'
| 'links'
| 'polls'
| 'journey'
| 'collections'
@@ -212,6 +213,21 @@ function SceneBack({ scene }: { scene: TrekScene }) {
</g>
</g>
)
case 'links':
// two chain links clicking into each other beside the body. The lower one
// carries a surface-coloured under-stroke so it reads as passing through
// the upper one rather than lying flat on top of it.
return (
<g className="trek-chain">
<g transform="rotate(-36 73 56)">
<rect x="58" y="49" width="20" height="13" rx="6.5" fill="none" stroke="currentColor" strokeWidth="3" />
<g className="trek-link">
<rect x="68" y="49" width="20" height="13" rx="6.5" fill="none" stroke="var(--m-bg)" strokeWidth="5.6" />
<rect x="68" y="49" width="20" height="13" rx="6.5" fill="none" stroke="currentColor" strokeWidth="3" />
</g>
</g>
</g>
)
case 'polls':
// vote bars gently rising and settling like live tallies
return (
+7 -1
View File
@@ -233,6 +233,10 @@
@keyframes trek-handle { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
/* collections map pin bobbing */
@keyframes trek-pin { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
/* Chain: the pair sways, and the near link slides out along its own axis and
clicks back in. Moving only the near one keeps the joint readable at 88px. */
@keyframes trek-chain { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-6deg); } }
@keyframes trek-link { 0%, 62%, 100% { transform: translateX(0); } 30% { transform: translateX(2.6px); } }
/* atlas globe meridian drifting to fake a slow spin */
@keyframes trek-globe { 0%, 100% { transform: translateX(-2px); } 50% { transform: translateX(2px); } }
/* costs top coin bouncing on */
@@ -274,6 +278,8 @@
.trek-suitcase { animation: trek-suitcase 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-handle { animation: trek-handle 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-pin { animation: trek-pin 2.2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-chain { animation: trek-chain 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-link { animation: trek-link 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-globe { animation: trek-globe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.trek-coin { animation: trek-coin 2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
.trek-page { animation: trek-page 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
@@ -323,7 +329,7 @@
.trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport,
.trek-board, .trek-wheel, .trek-flag, .trek-ticket, .trek-note, .trek-pencil, .trek-write, .trek-bar,
.trek-suitcase, .trek-handle, .trek-pin, .trek-globe, .trek-coin, .trek-page, .trek-plane,
.trek-magnifier, .trek-z, .trek-check { animation: none; }
.trek-magnifier, .trek-z, .trek-check, .trek-chain, .trek-link { animation: none; }
/* Zzz would sit invisible at opacity 0 without their animation — pin them visible */
.trek-z { opacity: 1; }
}