feat(help): in-app help center with per-screen guides and pictures

A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
This commit is contained in:
Maurice
2026-09-21 23:20:27 +02:00
parent 151e32107f
commit 4346d7f2c1
483 changed files with 47654 additions and 33 deletions
+11
View File
@@ -0,0 +1,11 @@
import { useHelpContext } from '../../hooks/useHelpContext'
/**
* Renders nothing; registers the screen it sits in with the help center. A
* page body may not call hooks itself (see pages/PATTERN.md), so this is the
* form a `*Page.tsx` uses.
*/
export default function HelpAnchor({ id }: { id: string }): null {
useHelpContext(id)
return null
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
/**
* The one pill the help center is built from: section markers, counters,
* breadcrumbs, step numbers. Tones map onto the appearance tokens so a user's
* accent reaches every badge, and a badge can nest another (`count`) for the
* "3 steps" pattern without a second component.
*/
export type HelpBadgeTone = 'neutral' | 'accent' | 'soft' | 'success' | 'warning' | 'outline'
const TONES: Record<HelpBadgeTone, string> = {
neutral: 'bg-surface-tertiary text-content-secondary',
accent: 'bg-accent text-accent-text',
soft: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
outline: 'border border-edge bg-surface-card text-content-secondary',
}
/** The nested counter inverts against its parent so it reads as a badge in a badge. */
const COUNT_TONES: Record<HelpBadgeTone, string> = {
neutral: 'bg-surface-card text-content',
accent: 'bg-accent-text text-accent',
soft: 'bg-surface-card text-content',
success: 'bg-surface-card text-success',
warning: 'bg-surface-card text-warning',
outline: 'bg-surface-tertiary text-content',
}
export default function HelpBadge({
tone = 'neutral',
icon: Icon,
count,
uppercase = false,
children,
className = '',
}: {
tone?: HelpBadgeTone
icon?: LucideIcon
/** A nested badge on the right, e.g. a number. */
count?: React.ReactNode
uppercase?: boolean
children?: React.ReactNode
className?: string
}): React.ReactElement {
return (
<span
className={`inline-flex items-center gap-1.5 h-7 rounded-full pl-2.5 text-caption font-semibold leading-none whitespace-nowrap ${
count === undefined ? 'pr-2.5' : 'pr-1'
} ${uppercase ? 'uppercase tracking-[0.08em]' : ''} ${TONES[tone]} ${className}`}
>
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" />}
{children}
{count !== undefined && (
<span className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-caption font-bold ${COUNT_TONES[tone]}`}>
{count}
</span>
)}
</span>
)
}
+32
View File
@@ -0,0 +1,32 @@
import React from 'react'
import { HelpCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore } from '../../store/helpStore'
/**
* The `?` in the navbar. Same 32px footprint as the theme toggle beside it;
* lit while the panel is open so the two read as one control.
*/
export default function HelpButton({ className }: { className?: string } = {}): React.ReactElement {
const { t } = useTranslation()
const open = useHelpStore(s => s.open)
const toggleHelp = useHelpStore(s => s.toggleHelp)
return (
<button
type="button"
onClick={toggleHelp}
title={t('help.center.button')}
aria-label={t('help.center.button')}
aria-pressed={open}
data-help-button
className={
className ??
`hidden sm:flex relative w-8 h-8 p-2 rounded-lg items-center justify-center flex-shrink-0 transition-colors ${
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
}`
}
>
<HelpCircle className="w-4 h-4" />
</button>
)
}
@@ -0,0 +1,27 @@
import React from 'react'
import { Link } from 'react-router'
import { FileText, ArrowUpRight } from 'lucide-react'
import { useHelpStore } from '../../store/helpStore'
/**
* A row that leads into the full Help & Docs page. The panel closes on the
* way: the docs page has its own navigation and the two side by side would
* fight for the width.
*/
export default function HelpDocLink({ to, title, subtitle }: { to: string; title: string; subtitle?: string }): React.ReactElement {
const closeHelp = useHelpStore(s => s.closeHelp)
return (
<Link
to={to}
onClick={closeHelp}
className="group flex items-center gap-2.5 rounded-lg px-2 py-1.5 -mx-2 hover:bg-surface-hover transition-colors"
>
<FileText className="w-4 h-4 text-content-faint flex-shrink-0" />
<span className="min-w-0 flex-1">
<span className="block text-body text-content-secondary group-hover:text-content truncate transition-colors">{title}</span>
{subtitle && <span className="block text-caption text-content-faint truncate">{subtitle}</span>}
</span>
<ArrowUpRight className="w-3.5 h-3.5 text-content-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</Link>
)
}
@@ -0,0 +1,30 @@
import React from 'react'
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { useHelpStore } from '../../store/helpStore'
import { getHelpGuide } from '../../help/registry'
import HelpGuideView from './HelpGuideView'
const initial = useHelpStore.getState()
beforeEach(() => {
useHelpStore.setState(initial, true)
})
describe('HelpGuideView: links', () => {
it('offers the in-app link of a guide that has one and closes the dialog on the way', () => {
useHelpStore.setState({ open: true })
render(<HelpGuideView guide={getHelpGuide('widgets')!} />)
const link = screen.getByRole('link', { name: /Open Appearance settings/ })
expect(link).toHaveAttribute('href', '/settings?tab=appearance')
fireEvent.click(link)
expect(useHelpStore.getState().open).toBe(false)
})
it('lists every step and has no link for a quick guide', () => {
render(<HelpGuideView guide={getHelpGuide('delete-trip')!} />)
expect(screen.getAllByLabelText(/^Step \d$/)).toHaveLength(getHelpGuide('delete-trip')!.steps)
expect(screen.queryByRole('link', { name: /Open Appearance settings/ })).toBeNull()
expect(screen.getByRole('link', { name: /Open in Help & Docs/ })).toHaveAttribute('href', '/help/My-Trips-Dashboard#per-trip-actions')
})
})
@@ -0,0 +1,230 @@
import React from 'react'
import { Link } from 'react-router'
import { CheckCircle2, Lightbulb, ArrowUpRight, ChevronRight, Maximize2, ListOrdered, Camera, Link2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore, docTitle } from '../../store/helpStore'
import { guideKey, guideStepKey, guideTipKey, helpMedia, docsRoute, getHelpGuide, getHelpContext, ctxKey, helpContextTrail } from '../../help/registry'
import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
/**
* One guide, top to bottom: a header card that says where you are and how big
* the task is, the steps as a numbered rail with a picture each, what the
* reader ends up with, the things worth knowing, and where to go next.
*/
export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.ReactElement {
const { t } = useTranslation()
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
const steps = Array.from({ length: guide.steps }, (_, i) => i + 1)
const tips = Array.from({ length: guide.tips }, (_, i) => t(guideTipKey(guide.id, i + 1)))
const related = (guide.related ?? []).map(getHelpGuide).filter((g): g is HelpGuide => g !== null)
const Icon = helpIcon(guide.icon)
const ScreenIcon = screen ? helpIcon(screen.icon) : Icon
const screenRoute = useScreenRoute(screen?.route)
return (
<div className="trek-help-view flex flex-col gap-6">
{/* Where you are, what this is, how big it is */}
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-1.5">
{screen && helpContextTrail(screen).map((c, i, all) => (
<React.Fragment key={c.id}>
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-content-faint" />}
<HelpBadge tone="outline" icon={i === all.length - 1 ? ScreenIcon : undefined}>{t(ctxKey(c.id, 'title'))}</HelpBadge>
</React.Fragment>
))}
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
<HelpBadge tone="outline">{t('help.center.howTo')}</HelpBadge>
</div>
<div className="flex items-start gap-4">
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
<Icon className="w-6 h-6" />
</span>
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
<h1 className="text-title font-bold text-content leading-tight">{title}</h1>
<p className="text-body text-content-secondary leading-relaxed">{t(guideKey(guide.id, 'goal'))}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
<HelpSizeChip size={guide.size} />
<HelpBadge tone="neutral" icon={ListOrdered} count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
{guide.media.steps && <HelpBadge tone="neutral" icon={Camera} count={guide.steps}>{t('help.center.screenshot')}</HelpBadge>}
{/* One way out of the header: into the screen. The docs link waits at the bottom. */}
{screen && screenRoute && (
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
{t('help.center.goToScreen', { screen: t(ctxKey(screen.id, 'title')) })}
</HelpBadge>
</Link>
)}
</div>
</header>
{/* Steps, on a rail */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
</div>
<ol className="relative flex flex-col gap-3">
{/* The rail: runs behind the step numbers from the first to the last. */}
<span className="absolute left-[27px] top-6 bottom-6 w-px bg-edge" aria-hidden="true" />
{steps.map(n => {
const text = t(guideStepKey(guide.id, n))
const src = helpMedia.step(guide.id, n)
const alt = t('help.center.imageAlt', { n, title })
return (
<li
key={n}
className={`trek-help-step relative grid grid-cols-1 gap-4 rounded-2xl border border-edge bg-surface-card p-3 ${
guide.media.steps ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
}`}
>
<div className="flex gap-3 items-start">
<span
aria-label={t('help.center.step', { n })}
className="trek-help-step-no relative z-[1] w-8 h-8 rounded-full bg-surface-card text-content text-caption font-bold flex items-center justify-center flex-shrink-0 border border-edge"
>
{n}
</span>
<div className="min-w-0 flex-1 flex flex-col gap-2">
<HelpBadge tone="outline" uppercase className="self-start">
{t('help.center.stepOf', { n, total: guide.steps })}
</HelpBadge>
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{text}</p>
</div>
</div>
{guide.media.steps && (
<figure className="relative">
<button
type="button"
onClick={() => openLightbox({ src, alt })}
className="trek-help-shot group relative block w-full rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
aria-label={alt}
>
<img src={src} alt="" loading="lazy" decoding="async" className="block w-full aspect-[16/10] object-cover" />
<span className="absolute bottom-2 left-2">
<HelpBadge tone="accent" icon={Camera} count={n}>{t('help.center.screenshot')}</HelpBadge>
</span>
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<Maximize2 className="w-3.5 h-3.5" />
</span>
</button>
</figure>
)}
</li>
)
})}
</ol>
</section>
{/* Result */}
<section
className={`rounded-2xl border border-edge bg-surface-card p-4 grid grid-cols-1 gap-4 items-start ${
guide.media.result ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
}`}
>
<div className="flex flex-col gap-2.5">
<HelpBadge tone="success" icon={CheckCircle2} uppercase className="self-start">{t('help.center.result')}</HelpBadge>
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{t(guideKey(guide.id, 'result'))}</p>
</div>
{guide.media.result && (
<button
type="button"
onClick={() => openLightbox({ src: helpMedia.result(guide.id), alt: t(guideKey(guide.id, 'result')) })}
className="trek-help-shot group relative rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
>
<img src={helpMedia.result(guide.id)} alt="" loading="lazy" decoding="async" className="block w-full aspect-video object-cover" />
<span className="absolute bottom-2 left-2">
<HelpBadge tone="success" icon={CheckCircle2}>{t('help.center.result')}</HelpBadge>
</span>
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
<Maximize2 className="w-3.5 h-3.5" />
</span>
</button>
)}
</section>
{/* Tips */}
{tips.length > 0 && (
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="warning" icon={Lightbulb} uppercase count={tips.length} className="self-start">{t('help.center.tips')}</HelpBadge>
<ul className="flex flex-col gap-2">
{tips.map((tip, i) => (
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
<span className="w-6 h-6 rounded-full bg-warning-soft text-warning text-caption font-bold flex items-center justify-center flex-shrink-0">
{i + 1}
</span>
<span>{tip}</span>
</li>
))}
</ul>
</section>
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
<Link
to={guide.link}
onClick={closeHelp}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-accent text-accent-text text-body font-semibold px-4 py-2.5 hover:bg-accent-hover transition-colors"
>
{t(guideKey(guide.id, 'link'))}
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
)}
</div>
)}
{related.length > 0 && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={ChevronRight} uppercase count={related.length} className="self-start">{t('help.center.related')}</HelpBadge>
<ul className="flex flex-col gap-1">
{related.map(g => {
const RelIcon = helpIcon(g.icon)
return (
<li key={g.id}>
<button
type="button"
onClick={() => openGuide(g.id)}
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-surface-hover transition-colors"
>
<span className="w-7 h-7 rounded-md bg-surface-tertiary text-content-muted flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
<RelIcon className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1 text-body text-content-secondary group-hover:text-content truncate transition-colors">
{t(guideKey(g.id, 'title'))}
</span>
{/* A guide on another screen says which, so the jump is no surprise. */}
{g.context === guide.context ? (
<HelpBadge tone="neutral" count={g.steps}>{t('help.center.stepsLabel')}</HelpBadge>
) : (
<HelpBadge tone="outline" icon={ArrowUpRight}>{t(ctxKey(g.context, 'title'))}</HelpBadge>
)}
</button>
</li>
)
})}
</ul>
</div>
)}
</section>
)}
</div>
)
}
+237
View File
@@ -0,0 +1,237 @@
import React, { useState } from 'react'
import { Link } from 'react-router'
import { ArrowRight, ArrowUpRight, BookOpen, LayoutList, ListOrdered, Compass, ChevronRight, ChevronDown, Camera, LayoutGrid } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore, docTitle } from '../../store/helpStore'
import { ctxKey, ctxBulletKey, guideKey, guidesFor, helpMedia, docsRoute, helpContextTrail, childHelpContexts } from '../../help/registry'
import type { HelpContext, HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
/**
* The overview of a screen: what it is, what is on it, and a card per guide.
* With no registered context (a screen that has no help yet) it says so and
* leaves the search box as the way forward.
*/
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
const closeHelp = useHelpStore(s => s.closeHelp)
// Folded until asked for: the guides below matter more, the tabs are one click away.
const [subScreensOpen, setSubScreensOpen] = useState(false)
const screenRoute = useScreenRoute(context?.route)
if (!context) {
return (
<div className="trek-help-view flex flex-col items-center text-center gap-2 py-24">
<BookOpen className="w-8 h-8 text-content-faint" />
<p className="text-subtitle font-semibold text-content">{t('help.center.noContext')}</p>
<p className="text-body text-content-muted max-w-[320px]">{t('help.center.noContextHint')}</p>
</div>
)
}
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const Icon = helpIcon(context.icon)
return (
<div className="trek-help-view flex flex-col gap-6">
{/* Which screen, what it is, what help there is */}
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-1.5">
<HelpBadge tone="outline" icon={Compass}>{t('help.center.screens')}</HelpBadge>
{helpContextTrail(context).slice(0, -1).map(parent => (
<React.Fragment key={parent.id}>
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
<HelpBadge tone="outline">{t(ctxKey(parent.id, 'title'))}</HelpBadge>
</React.Fragment>
))}
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
<HelpBadge tone="outline">{t('help.center.overview')}</HelpBadge>
</div>
<div className="flex items-start gap-4">
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
<Icon className="w-6 h-6" />
</span>
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
<h1 className="text-title font-bold text-content leading-tight">{t(ctxKey(context.id, 'title'))}</h1>
<p className="text-body text-content-secondary leading-relaxed">{t(ctxKey(context.id, 'summary'))}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
<HelpBadge tone="accent" icon={ListOrdered} count={guides.length}>{t('help.center.searchGuides')}</HelpBadge>
{context.docs.length > 0 && <HelpBadge tone="neutral" icon={BookOpen} count={context.docs.length}>{t('help.center.searchDocs')}</HelpBadge>}
{screenRoute && (
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
{t('help.center.goToScreen', { screen: t(ctxKey(context.id, 'title')) })}
</HelpBadge>
</Link>
)}
</div>
</header>
{/* Screens under this one (the tabs of Settings, the journal of Journey) */}
{children.length > 0 && (
<section className="rounded-2xl border border-edge bg-surface-card flex flex-col">
<button
type="button"
onClick={() => setSubScreensOpen(o => !o)}
aria-expanded={subScreensOpen}
className="flex items-center gap-3 p-4 text-left rounded-2xl hover:bg-surface-hover transition-colors"
>
<HelpBadge tone="neutral" icon={LayoutGrid} uppercase count={children.length}>{t('help.center.subScreensLabel')}</HelpBadge>
<span className="min-w-0 flex-1 text-caption text-content-muted truncate">
{children.map(child => t(ctxKey(child.id, 'title'))).join(' · ')}
</span>
<ChevronDown className={`w-4 h-4 text-content-faint flex-shrink-0 transition-transform ${subScreensOpen ? 'rotate-180' : ''}`} />
</button>
{subScreensOpen && (
<ul className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2 px-4 pb-4">
{children.map(child => (
<li key={child.id}>
<SubScreenCard screen={child} />
</li>
))}
</ul>
)}
</section>
)}
{/* The screen itself */}
{context.hero && (
<figure className="relative rounded-2xl overflow-hidden border border-edge-secondary bg-surface-tertiary shadow-card">
<img
src={helpMedia.hero(context.id)}
alt=""
loading="lazy"
decoding="async"
className="block w-full aspect-video object-cover"
/>
<span className="absolute bottom-3 left-3">
<HelpBadge tone="accent" icon={Camera}>{t(ctxKey(context.id, 'title'))}</HelpBadge>
</span>
</figure>
)}
{/* What is on it */}
{bullets.length > 0 && (
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={LayoutList} uppercase count={bullets.length} className="self-start">{t('help.center.onThisScreen')}</HelpBadge>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{bullets.map((text, i) => (
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
<span className="w-6 h-6 rounded-full bg-accent text-accent-text text-caption font-bold flex items-center justify-center flex-shrink-0">
{i + 1}
</span>
<span>{text}</span>
</li>
))}
</ul>
</section>
)}
{/* Guides */}
{guides.length > 0 && (
<section className="flex flex-col gap-3">
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guides.length} className="self-start">{t('help.center.howTo')}</HelpBadge>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{guides.map(g => (
<li key={g.id}>
<GuideCard guide={g} />
</li>
))}
</ul>
</section>
)}
{/* Docs */}
{context.docs.length > 0 && (
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-2">
<HelpBadge tone="neutral" icon={BookOpen} uppercase count={context.docs.length} className="self-start">{t('help.center.docsSection')}</HelpBadge>
<ul className="flex flex-col">
{context.docs.map(link => (
<li key={`${link.slug}#${link.anchor ?? ''}`}>
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
</li>
))}
</ul>
</section>
)}
</div>
)
}
/** One sub-screen: opens its overview in place; the one the reader is on says so. */
function SubScreenCard({ screen }: { screen: HelpContext }): React.ReactElement {
const { t } = useTranslation()
const browse = useHelpStore(s => s.browse)
const contextId = useHelpStore(s => s.contextId)
const current = screen.id === contextId
const Icon = helpIcon(screen.icon)
return (
<button
type="button"
onClick={() => browse(current ? null : screen.id)}
className="group w-full h-full flex items-center gap-3 rounded-xl border border-edge bg-surface-secondary px-3 py-2.5 text-left hover:border-content-faint hover:bg-surface-card transition-colors"
>
<span className="w-9 h-9 rounded-lg bg-surface-card text-content-secondary flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
<Icon className="w-4 h-4" />
</span>
<span className="min-w-0 flex-1 flex flex-col gap-0.5">
<span className="text-body font-semibold text-content truncate">{t(ctxKey(screen.id, 'title'))}</span>
<span className="text-caption text-content-muted">{t('help.center.guidesCount', { count: screen.guides.length })}</span>
</span>
{current ? <ThisScreenChip /> : <ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />}
</button>
)
}
/** The marker for the screen the reader is actually on. */
export function ThisScreenChip(): React.ReactElement {
const { t } = useTranslation()
return (
<span className="inline-flex items-center rounded-full bg-accent text-accent-text px-2 py-1 text-caption font-semibold leading-none whitespace-nowrap flex-shrink-0">
{t('help.center.thisScreen')}
</span>
)
}
export function SectionLabel({ children }: { children: React.ReactNode }): React.ReactElement {
return <h3 className="text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
}
export function GuideCard({ guide }: { guide: HelpGuide }): React.ReactElement {
const { t } = useTranslation()
const openGuide = useHelpStore(s => s.openGuide)
const Icon = helpIcon(guide.icon)
return (
<button
type="button"
onClick={() => openGuide(guide.id)}
className="group w-full h-full flex flex-col gap-3 rounded-2xl border border-edge bg-surface-card p-4 text-left hover:border-content-faint hover:shadow-elevated transition-[border-color,box-shadow,transform] duration-200 ease-[var(--ease-out-quint)] hover:-translate-y-px"
>
<span className="flex items-center justify-between">
<span className="w-10 h-10 rounded-xl bg-surface-tertiary text-content-secondary flex items-center justify-center group-hover:bg-accent group-hover:text-accent-text transition-colors">
<Icon className="w-4 h-4" />
</span>
<span className="flex items-center gap-1.5">
<HelpSizeChip size={guide.size} />
</span>
</span>
<span className="flex flex-col gap-1 flex-1">
<span className="text-body font-semibold text-content leading-snug">{t(guideKey(guide.id, 'title'))}</span>
<span className="text-caption text-content-muted leading-relaxed">{t(guideKey(guide.id, 'goal'))}</span>
</span>
<span className="flex items-center justify-between pt-3 border-t border-edge-secondary">
<HelpBadge tone="neutral" count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
<ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />
</span>
</button>
)
}
@@ -0,0 +1,35 @@
import React from 'react'
import { X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore } from '../../store/helpStore'
/** A step picture enlarged over the help dialog; click anywhere or Escape to close. */
export default function HelpLightbox(): React.ReactElement | null {
const { t } = useTranslation()
const lightbox = useHelpStore(s => s.lightbox)
const close = useHelpStore(s => s.openLightbox)
if (!lightbox) return null
return (
<div
className="trek-help-lightbox fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-4 sm:p-8 bg-[var(--overlay)] backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label={lightbox.alt}
onClick={() => close(null)}
>
<button
type="button"
onClick={() => close(null)}
aria-label={t('help.center.close')}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-inverse text-inverse-text flex items-center justify-center shadow-elevated hover:scale-105 transition-transform"
>
<X className="w-5 h-5" />
</button>
<figure className="trek-help-lightbox-card max-w-[min(1400px,94vw)] max-h-[90vh] flex flex-col gap-3" onClick={e => e.stopPropagation()}>
<img src={lightbox.src} alt={lightbox.alt} className="max-h-[84vh] w-auto object-contain rounded-xl border border-edge shadow-modal bg-surface-card" />
{lightbox.alt && <figcaption className="text-caption text-center text-inverse-text opacity-80">{lightbox.alt}</figcaption>}
</figure>
</div>
)
}
+393
View File
@@ -0,0 +1,393 @@
import React, { useEffect, useRef, useState } from 'react'
import { Search, LifeBuoy, ArrowUpRight, ChevronsUpDown, ChevronDown, ChevronRight, Check, MapPin, CornerDownRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore, docTitle } from '../../store/helpStore'
import { ctxKey, guideKey, guidesFor, docsRoute, topLevelHelpContexts, childHelpContexts, helpContextTrail } from '../../help/registry'
import type { HelpContext, HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpDocLink from './HelpDocLink'
import { ThisScreenChip } from './HelpHome'
const GITHUB_DISCUSSIONS = 'https://github.com/liketrek/TREK/discussions/new?category=ideas'
const DISCORD = 'https://discord.gg/NhZBDSd4qW'
/**
* Left pane of the help dialog: search, the current screen with its guides,
* the docs that go with it, and the ways to reach a human.
*/
export default function HelpNav({ context, currentId, activeGuideId }: {
/** The screen whose help is shown. */
context: HelpContext | null
/** The screen the reader is actually on, for the "this screen" marker. */
currentId: string | null
activeGuideId: string | null
}): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const setQuery = useHelpStore(s => s.setQuery)
const closeGuide = useHelpStore(s => s.closeGuide)
const docIndex = useHelpStore(s => s.docIndex)
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
searchRef.current?.focus({ preventScroll: true })
}, [])
const guides = context ? guidesFor(context) : []
const overviewActive = !activeGuideId && !query.trim()
// The tabs of the screen on show, or of its parent when the reader is on one of them.
const family = context ? childHelpContexts(context.parent ?? context.id) : []
// Folded by default: the guides matter more, the tabs are one click away.
const [familyOpen, setFamilyOpen] = useState(false)
return (
<nav className="flex flex-col min-h-0 border-b md:border-b-0 md:border-r border-edge bg-surface-secondary" aria-label={t('help.center.title')}>
<div className="px-5 pt-5 pb-3 flex items-center gap-2.5">
<span className="w-8 h-8 rounded-lg flex items-center justify-center bg-accent text-accent-text">
<LifeBuoy className="w-4 h-4" />
</span>
<span className="text-subtitle font-bold text-content leading-none">{t('help.center.title')}</span>
</div>
<div className="px-5 pb-4">
<label className="relative block">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
<input
ref={searchRef}
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('help.center.searchPlaceholder')}
aria-label={t('help.center.searchPlaceholder')}
className="w-full rounded-xl bg-surface-card text-content text-body pl-9 pr-3 py-2.5 outline-none border border-edge focus:border-content-faint transition-colors placeholder:text-content-faint"
/>
</label>
</div>
<div className="trek-help-scroll flex-1 min-h-0 overflow-y-auto px-3 pb-3 flex flex-col gap-5">
<section className="flex flex-col gap-1">
<NavLabel>{t('help.center.screens')}</NavLabel>
<ScreenSwitcher shown={context} currentId={currentId} />
</section>
{context ? (
<>
{family.length > 0 && (
<section className="flex flex-col gap-1">
<button
type="button"
onClick={() => setFamilyOpen(o => !o)}
aria-expanded={familyOpen}
className="flex items-center gap-1.5 px-2 pb-1 text-left text-caption font-semibold uppercase tracking-[0.1em] text-content-faint hover:text-content transition-colors"
>
<span className="flex-1">{t('help.center.subScreensLabel')}</span>
<span className="text-caption font-bold tabular-nums normal-case tracking-normal">{family.length}</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${familyOpen ? 'rotate-180' : ''}`} />
</button>
{familyOpen && (
<ul className="flex flex-col gap-0.5">
{family.map(screen => (
<li key={screen.id}>
<NavScreen screen={screen} shown={screen.id === context.id} current={screen.id === currentId} />
</li>
))}
</ul>
)}
</section>
)}
<section className="flex flex-col gap-1">
<NavLabel>{t('help.center.overview')}</NavLabel>
<button
type="button"
onClick={() => { closeGuide(); setQuery('') }}
aria-current={overviewActive ? 'page' : undefined}
className={`flex items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
overviewActive ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
<span className="text-body font-semibold truncate">{t(ctxKey(context.id, 'title'))}</span>
</button>
</section>
{guides.length > 0 && (
<section className="flex flex-col gap-1">
<NavLabel>{t('help.center.howTo')}</NavLabel>
<ul className="flex flex-col gap-0.5">
{guides.map(g => (
<li key={g.id}>
<NavGuide guide={g} active={g.id === activeGuideId} />
</li>
))}
</ul>
</section>
)}
{context.docs.length > 0 && (
<section className="flex flex-col gap-1 px-2">
<NavLabel>{t('help.center.docsSection')}</NavLabel>
<ul className="flex flex-col">
{context.docs.map(link => (
<li key={`${link.slug}#${link.anchor ?? ''}`}>
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
</li>
))}
</ul>
</section>
)}
</>
) : (
<p className="px-2 text-body text-content-muted">{t('help.center.noContext')}</p>
)}
</div>
<footer className="px-4 py-4 border-t border-edge flex flex-col gap-2">
<span className="px-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{t('help.center.feedback')}</span>
<a
href={GITHUB_DISCUSSIONS}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-xl bg-accent text-accent-text px-3.5 py-2.5 hover:bg-accent-hover transition-colors"
>
<GitHubMark className="w-5 h-5 flex-shrink-0" />
<span className="flex-1 text-body font-semibold">{t('help.center.feedbackLink')}</span>
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
{/* Discord blurple is a brand colour, deliberately not a theme token. */}
<a
href={DISCORD}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-xl bg-[#5865F2] text-white px-3.5 py-2.5 hover:bg-[#4752C4] transition-colors" // theme-lint-disable
>
<DiscordMark className="w-5 h-5 flex-shrink-0" />
<span className="flex-1 text-body font-semibold">{t('help.center.discord')}</span>
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
</footer>
</nav>
)
}
/**
* Pick which screen's help to read without leaving the one you are on. The
* current screen is marked; choosing it again returns the dialog to following
* the screen the reader is on.
*/
/**
* Which screen the panel shows, and the list to pick another. Screens with
* sub-screens (Journey and its journal, Settings and its tabs) fold: one group
* is open at a time, and the group of the screen on show starts open.
*/
function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; currentId: string | null }): React.ReactElement {
const { t } = useTranslation()
const browse = useHelpStore(s => s.browse)
const [open, setOpen] = useState(false)
const [openGroup, setOpenGroup] = useState<string | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const screens = topLevelHelpContexts()
const ShownIcon = shown ? helpIcon(shown.icon) : MapPin
const trail = shown ? helpContextTrail(shown) : []
// Click outside or Escape closes the list; Escape must not bubble into the dialog's own close.
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) } }
document.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey, true)
}
}, [open])
const choose = (id: string) => {
browse(id === currentId ? null : id)
setOpen(false)
}
const toggleList = () => {
setOpen(o => {
if (!o) setOpenGroup(trail.length > 1 ? trail[0].id : null)
return !o
})
}
return (
<div ref={rootRef} className="relative px-2">
<button
type="button"
onClick={toggleList}
aria-haspopup="listbox"
aria-expanded={open}
className="w-full flex items-center gap-2.5 rounded-xl border border-edge bg-surface-card px-3 py-2.5 text-left hover:border-content-faint transition-colors"
>
<span className="w-7 h-7 rounded-md bg-accent text-accent-text flex items-center justify-center flex-shrink-0">
<ShownIcon className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-body font-semibold text-content truncate">
{shown ? trail.map(c => t(ctxKey(c.id, 'title'))).join(' › ') : t('help.center.noContext')}
</span>
</span>
{shown && shown.id === currentId && <ThisScreenChip />}
<ChevronsUpDown className="w-4 h-4 text-content-faint flex-shrink-0" />
</button>
{open && (
<ul
role="listbox"
aria-label={t('help.center.screens')}
className="trek-help-menu absolute left-2 right-2 top-full mt-1.5 z-10 flex flex-col gap-0.5 rounded-xl border border-edge bg-surface-card p-1.5 shadow-elevated"
>
{screens.map(screen => {
const children = childHelpContexts(screen.id)
const expanded = openGroup === screen.id
return (
<li key={screen.id} className="flex flex-col gap-0.5">
<ScreenRow
screen={screen}
selected={screen.id === shown?.id}
current={screen.id === currentId}
onPick={() => choose(screen.id)}
group={children.length > 0 ? { expanded, count: children.length, toggle: () => setOpenGroup(expanded ? null : screen.id) } : undefined}
/>
{children.length > 0 && expanded && (
<ul role="group" className="flex flex-col gap-0.5 pl-3">
{children.map(child => (
<li key={child.id}>
<ScreenRow
screen={child}
selected={child.id === shown?.id}
current={child.id === currentId}
onPick={() => choose(child.id)}
nested
/>
</li>
))}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
)
}
/** One screen in the switcher list; a group row carries the fold toggle on its right. */
function ScreenRow({
screen, selected, current, onPick, group, nested = false,
}: {
screen: HelpContext
selected: boolean
current: boolean
onPick: () => void
group?: { expanded: boolean; count: number; toggle: () => void }
nested?: boolean
}): React.ReactElement {
const { t } = useTranslation()
const Icon = helpIcon(screen.icon)
const Fold = group?.expanded ? ChevronDown : ChevronRight
return (
<div
role="option"
aria-selected={selected}
className={`flex items-center rounded-lg transition-colors ${
selected ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
<button type="button" onClick={onPick} className="min-w-0 flex-1 flex items-center gap-2.5 px-2 py-2 text-left">
{nested ? (
<CornerDownRight className="w-3.5 h-3.5 flex-shrink-0 text-content-faint" />
) : (
<Icon className="w-4 h-4 flex-shrink-0 text-content-muted" />
)}
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
{current && <ThisScreenChip />}
{selected && <Check className="w-4 h-4 flex-shrink-0" />}
</button>
{group && (
<button
type="button"
onClick={e => { e.stopPropagation(); group.toggle() }}
aria-expanded={group.expanded}
aria-label={t('help.center.subScreens', { count: group.count })}
className="flex items-center gap-1 rounded-md mr-1.5 px-1.5 py-1 text-caption font-semibold text-content-faint hover:bg-surface-tertiary hover:text-content transition-colors"
>
{group.count}
<Fold className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}
/** A sibling or child screen in the nav; picking it browses that screen's overview. */
function NavScreen({ screen, shown, current }: { screen: HelpContext; shown: boolean; current: boolean }): React.ReactElement {
const { t } = useTranslation()
const browse = useHelpStore(s => s.browse)
const Icon = helpIcon(screen.icon)
return (
<button
type="button"
onClick={() => browse(current ? null : screen.id)}
aria-current={shown ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
shown ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
<span className={`w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
shown ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted group-hover:bg-accent group-hover:text-accent-text'
}`}>
<Icon className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
{current && <MapPin className="w-3.5 h-3.5 text-content-faint flex-shrink-0" aria-label={t('help.center.thisScreen')} />}
</button>
)
}
function NavLabel({ children }: { children: React.ReactNode }): React.ReactElement {
return <h3 className="px-2 pb-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
}
function NavGuide({ guide, active }: { guide: HelpGuide; active: boolean }): React.ReactElement {
const { t } = useTranslation()
const openGuide = useHelpStore(s => s.openGuide)
const Icon = helpIcon(guide.icon)
return (
<button
type="button"
onClick={() => openGuide(guide.id)}
aria-current={active ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
active ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}
>
<span className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted group-hover:text-content'
}`}>
<Icon className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1 text-body truncate">{t(guideKey(guide.id, 'title'))}</span>
</button>
)
}
/** GitHub's mark, from their brand kit; lucide dropped its brand icons. */
function GitHubMark({ className }: { className?: string }): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.17c-3.2.7-3.87-1.37-3.87-1.37-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.76 2.69 1.25 3.35.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.04 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.81 1.19 1.83 1.19 3.09 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
)
}
/** Discord's mark, the same path the navbar's profile menu uses. */
function DiscordMark({ className }: { className?: string }): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
)
}
@@ -0,0 +1,242 @@
import React from 'react'
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor, within, act } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { useHelpStore } from '../../store/helpStore'
import HelpPanel from './HelpPanel'
import HelpButton from './HelpButton'
import HelpAnchor from './HelpAnchor'
/**
* The dialog end to end against the real registry and the English catalogue:
* open it from the navbar button, read the overview, open a guide, search,
* close it again. The wiki index and search are served by msw.
*/
const initial = useHelpStore.getState()
beforeEach(() => {
useHelpStore.setState(initial, true)
server.use(
http.get('/api/help/index', () =>
HttpResponse.json({ sections: [{ title: 'Planning', pages: [{ slug: 'Creating-a-Trip', title: 'Creating a Trip' }, { slug: 'My-Trips-Dashboard', title: 'My Trips Dashboard' }] }] }),
),
http.get('/api/help/search', ({ request }) => {
const q = new URL(request.url).searchParams.get('q') ?? ''
if (q === 'zzqx') return HttpResponse.json({ hits: [] })
return HttpResponse.json({ hits: [{ slug: 'Calendar-Feeds', title: 'Calendar Feeds', section: 'Planning', anchor: 'turning-a-feed-on', heading: 'Turning a feed on', snippet: 'Click Enable calendar subscription…', score: 9 }] })
}),
)
})
function mount(): void {
render(
<>
<HelpButton />
<HelpAnchor id="dashboard" />
<HelpPanel />
</>,
)
}
describe('HelpPanel', () => {
it('stays out of the DOM until opened, then shows the screen overview', () => {
mount()
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
const dialog = screen.getByRole('dialog', { name: 'Help' })
expect(dialog).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
// Guides appear in the navigation and as cards.
expect(screen.getAllByRole('button', { name: /Create a trip/ }).length).toBeGreaterThanOrEqual(2)
expect(screen.getByRole('button', { name: 'Help for this screen' })).toHaveAttribute('aria-pressed', 'true')
})
it('opens a guide with its steps, result and tips, and goes back to the overview', async () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
expect(screen.getByText(/Click Create New Trip\./)).toBeInTheDocument()
expect(screen.getByText(/takes over the boarding pass/)).toBeInTheDocument()
expect(screen.getByText(/Dates can be changed later/)).toBeInTheDocument()
// A picture for every step.
expect(screen.getAllByRole('button', { name: /Step \d of/ })).toHaveLength(5)
// Doc link resolves its title from the index once that has loaded.
await waitFor(() => expect(screen.getAllByText('Creating a Trip').length).toBeGreaterThan(0))
// Related guide opens in place (the last match is the one under "Related"; the nav lists it too).
const related = screen.getAllByRole('button', { name: 'Set a cover photo' })
fireEvent.click(related[related.length - 1])
expect(screen.getByRole('heading', { level: 1, name: 'Set a cover photo' })).toBeInTheDocument()
// The overview entry in the nav returns home.
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
})
it('enlarges a step picture in the lightbox and closes it on click', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
fireEvent.click(screen.getAllByRole('button', { name: /Edit a trip/ })[0])
fireEvent.click(screen.getAllByRole('button', { name: /Step 1 of/ })[0])
const box = screen.getByRole('dialog', { name: /Step 1 of/ })
expect(box.querySelector('img')).toHaveAttribute('src', '/help-media/edit-trip/step-1.webp')
fireEvent.click(box)
expect(screen.queryByRole('dialog', { name: /Step 1 of/ })).toBeNull()
})
it('searches guides locally and the docs on the server', async () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
const box = screen.getByRole('searchbox')
fireEvent.change(box, { target: { value: 'calendar' } })
// Local guide match right away…
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
// …and the doc hit once the server answers.
await waitFor(() => expect(screen.getByText('Calendar Feeds · Turning a feed on')).toBeInTheDocument())
expect(screen.getByRole('link', { name: /Calendar Feeds · Turning a feed on/ })).toHaveAttribute('href', '/help/Calendar-Feeds#turning-a-feed-on')
})
it('finds a guide by its words in any order, stop words and accents aside, title first', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
const box = screen.getByRole('searchbox')
fireEvent.change(box, { target: { value: 'how do I create trip' } })
// The result cards sit in the main pane's "Guides" list, the nav lists guides too.
const first = () => screen.getByRole('main').querySelector('ul li button')!
expect(first()).toHaveAccessibleName(/Create a trip/)
fireEvent.change(box, { target: { value: 'Trip Créate' } })
expect(first()).toHaveAccessibleName(/Create a trip/)
})
it('searches from inside a guide too, and returns to the guide once the box is cleared', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
const box = screen.getByRole('searchbox')
fireEvent.change(box, { target: { value: 'calendar' } })
expect(screen.queryByRole('heading', { level: 1, name: 'Create a trip' })).toBeNull()
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
fireEvent.change(box, { target: { value: '' } })
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
})
it('lists the sub-screens of a screen folded on its overview and in the nav, and offers the way into the screen', () => {
render(
<>
<HelpButton />
<HelpAnchor id="settings" />
<HelpPanel />
</>,
)
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
// Folded: the names are listed in one line, the cards are not there yet.
const nav = screen.getByRole('navigation', { name: 'Help' })
const fold = screen.getAllByRole('button', { name: /Sub-screens/ }).find(b => !nav.contains(b))!
expect(fold).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: /Appearance.*guides/ })).toBeNull()
fireEvent.click(fold)
expect(fold).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: /Appearance.*guides/ }))
expect(useHelpStore.getState().browseId).toBe('settings-appearance')
expect(screen.getByRole('heading', { level: 1, name: 'Appearance' })).toBeInTheDocument()
// On a tab, the nav lists its siblings with the shown one marked, and the header leads into the tab.
// The nav folds them too; open it, then the siblings are listed with the shown one marked.
// (The overview entry is also named "Appearance"; the sibling rows are the ones under "Sub-screens".)
fireEvent.click(within(nav).getByRole('button', { name: /Sub-screens/ }))
const rows = within(nav).getAllByRole('button', { name: 'Appearance' })
expect(rows.some(b => b.getAttribute('aria-current') === 'page')).toBe(true)
expect(within(nav).getByRole('button', { name: 'Map' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go to Appearance' })).toHaveAttribute('href', '/settings?tab=appearance')
})
it('says so when nothing matches', async () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzqx' } })
await waitFor(() => expect(screen.getByText('Nothing found for “zzqx”.')).toBeInTheDocument())
})
it('closes on the close button, on the backdrop and on Escape', async () => {
mount()
const open = () => fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
open()
fireEvent.click(screen.getByRole('button', { name: 'Close help' }))
expect(useHelpStore.getState().open).toBe(false)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
open()
fireEvent.click(screen.getByRole('dialog').parentElement!)
expect(useHelpStore.getState().open).toBe(false)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
open()
fireEvent.keyDown(window, { key: 'Escape' })
expect(useHelpStore.getState().open).toBe(false)
})
it('leaves Escape to a modal the reader opened while following a guide', () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
const modal = document.createElement('div')
modal.className = 'trek-modal-backdrop'
document.body.appendChild(modal)
fireEvent.keyDown(window, { key: 'Escape' })
expect(useHelpStore.getState().open).toBe(true)
modal.remove()
})
it('shows the empty state on a screen without help', () => {
render(
<>
<HelpButton />
<HelpPanel />
</>,
)
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
expect(screen.getAllByText('No guide for this screen yet.').length).toBeGreaterThan(0)
})
it('clears the screen when its anchor unmounts', () => {
const { unmount } = render(<HelpAnchor id="dashboard" />)
expect(useHelpStore.getState().contextId).toBe('dashboard')
act(() => unmount())
expect(useHelpStore.getState().contextId).toBeNull()
})
it('folds sub-screens under their screen in the switcher, one group open at a time', () => {
render(
<>
<HelpButton />
<HelpAnchor id="journey-detail" />
<HelpPanel />
</>,
)
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
// The trigger names the trail, and the list opens with the journal's group unfolded.
const trigger = screen.getByRole('button', { name: /Journey › Journal/ })
fireEvent.click(trigger)
const list = screen.getByRole('listbox', { name: 'Screens' })
expect(list).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Journal/ })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('option', { name: /Studio/ })).toBeInTheDocument()
// Folding the group hides the sub-screens; the top-level screens stay.
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
expect(screen.queryByRole('option', { name: /Studio/ })).toBeNull()
expect(screen.getByRole('option', { name: /Atlas/ })).toBeInTheDocument()
// Picking a sub-screen browses to it; the breadcrumb on the overview shows the trail.
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
fireEvent.click(screen.getByRole('option', { name: /Studio/ }).querySelector('button')!)
expect(useHelpStore.getState().browseId).toBe('journey-studio')
expect(screen.getByRole('heading', { level: 1, name: 'Studio' })).toBeInTheDocument()
})
it('does not clear a screen another anchor has already taken over', () => {
const { unmount } = render(<HelpAnchor id="dashboard" />)
act(() => useHelpStore.getState().setContext('settings'))
act(() => unmount())
expect(useHelpStore.getState().contextId).toBe('settings')
})
})
+131
View File
@@ -0,0 +1,131 @@
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore } from '../../store/helpStore'
import { useHelpShortcut } from '../../hooks/useHelpShortcut'
import { getHelpContext, getHelpGuide } from '../../help/registry'
import HelpNav from './HelpNav'
import HelpHome from './HelpHome'
import HelpGuideView from './HelpGuideView'
import HelpSearchResults from './HelpSearchResults'
import HelpLightbox from './HelpLightbox'
import './help.css'
/** How long the closing animation runs before the dialog leaves the DOM. */
const CLOSE_MS = 160
/**
* The help center: one large dialog over the app. Navigation and search on the
* left, the screen overview or the open guide on the right. Escape is handled
* by useHelpShortcut; the `?` in the navbar is the only way in.
*/
export default function HelpPanel(): React.ReactElement | null {
const { t } = useTranslation()
const open = useHelpStore(s => s.open)
const contextId = useHelpStore(s => s.contextId)
const browseId = useHelpStore(s => s.browseId)
const guideId = useHelpStore(s => s.guideId)
const query = useHelpStore(s => s.query)
const closeHelp = useHelpStore(s => s.closeHelp)
const loadDocIndex = useHelpStore(s => s.loadDocIndex)
useHelpShortcut()
// Keep the dialog mounted for the length of the exit animation.
const [state, setState] = useState<'closed' | 'open' | 'closing'>(open ? 'open' : 'closed')
const wasOpen = useRef(open)
useEffect(() => {
if (open) {
wasOpen.current = true
setState('open')
return
}
if (!wasOpen.current) return
wasOpen.current = false
setState('closing')
const id = window.setTimeout(() => setState('closed'), CLOSE_MS)
return () => window.clearTimeout(id)
}, [open])
// Doc titles for the links; one fetch per session.
useEffect(() => {
if (open) void loadDocIndex()
}, [open, loadDocIndex])
// The page behind must not scroll while the dialog is up.
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [open])
// Focus back to the `?` when the dialog closes.
useEffect(() => {
if (open) return
const btn = document.querySelector<HTMLElement>('[data-help-button]')
if (btn && document.activeElement === document.body) btn.focus({ preventScroll: true })
}, [open])
// Every view change starts at the top of the content pane.
const shownId = browseId ?? contextId
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo?.({ top: 0 })
}, [guideId, query, shownId])
if (state === 'closed') return null
const context = getHelpContext(shownId)
const guide = getHelpGuide(guideId)
// Typing wins over whatever was open: a search from inside a guide shows
// results, and clearing the box returns to that guide.
const searching = query.trim().length > 0
return createPortal(
<>
<div
className="trek-help-backdrop fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-3 sm:p-5 bg-[var(--overlay)] backdrop-blur-sm"
data-state={state === 'open' ? 'open' : 'closing'}
role="presentation"
onClick={closeHelp}
>
<div
className="trek-help-dialog relative w-[min(1520px,100%)] h-[min(960px,100%)] grid grid-cols-1 md:grid-cols-[320px_minmax(0,1fr)] rounded-2xl border border-edge bg-surface-card shadow-modal overflow-hidden"
role="dialog"
aria-modal="true"
aria-label={t('help.center.title')}
onClick={e => e.stopPropagation()}
>
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 right-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
<HelpGuideView key={guide.id} guide={guide} />
) : (
<HelpHome key={context?.id ?? 'none'} context={context} />
)}
</div>
</div>
</div>
</div>
</div>
<HelpLightbox />
</>,
document.body,
)
}
@@ -0,0 +1,125 @@
import React, { useMemo } from 'react'
import { Loader2, SearchX } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useHelpStore } from '../../store/helpStore'
import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../../help/registry'
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
/** Words that carry no meaning on their own; "how do I create a trip" is "create trip". */
const STOP = new Set(['a', 'an', 'the', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'how', 'do', 'i', 'my', 'is', 'it', 'with'])
/** Lower-case, accents stripped, so "Fjorde" finds "fjorde" and "cafe" finds "Café". */
const fold = (text: string): string => text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** The query as the words that matter, or all of them when only stop words were typed. */
function queryWords(query: string): string[] {
const words = fold(query).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
const meaningful = words.filter(w => !STOP.has(w))
return meaningful.length > 0 ? meaningful : words
}
/**
* Score a guide against the query using the words the reader sees, in their
* language: title, goal, the screen it belongs to, steps, tips and result.
* Every word of the query has to occur somewhere; a word in the title counts
* most, and a title that holds all of them ranks first.
*/
function scoreGuide(guide: HelpGuide, words: string[], t: (key: string) => string): number {
if (words.length === 0) return 0
const fields: { text: string; weight: number }[] = [
{ text: fold(t(guideKey(guide.id, 'title'))), weight: 6 },
{ text: fold(t(guideKey(guide.id, 'goal'))), weight: 3 },
{ text: fold(t(ctxKey(guide.context, 'title'))), weight: 2 },
{ text: fold(t(guideKey(guide.id, 'result'))), weight: 1 },
]
for (let n = 1; n <= guide.steps; n++) fields.push({ text: fold(t(guideStepKey(guide.id, n))), weight: 1 })
for (let n = 1; n <= guide.tips; n++) fields.push({ text: fold(t(guideTipKey(guide.id, n))), weight: 1 })
let score = 0
for (const word of words) {
let best = 0
for (const f of fields) {
if (!f.text.includes(word)) continue
// A whole word beats a fragment: "trip" in "trips" is fine, "rip" in "trip" is not much.
const whole = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(word)}([^\\p{L}\\p{N}]|$)`, 'u').test(f.text)
best = Math.max(best, whole ? f.weight * 2 : f.weight)
}
if (best === 0) return 0
score += best
}
if (words.every(w => fields[0].text.includes(w))) score += 10
return score
}
/** Search across every registered guide and the docs; both lists live here. */
export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
const guides = useMemo(() => {
const words = queryWords(q)
return allHelpGuides()
.map(g => ({ g, score: scoreGuide(g, words, t) }))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_GUIDES)
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
return (
<div className="trek-help-view flex flex-col gap-8">
<h1 className="text-title font-bold text-content leading-tight">{query.trim()}</h1>
{guides.length > 0 && (
<section className="flex flex-col gap-2">
<SectionLabel>{t('help.center.searchGuides')}</SectionLabel>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{guides.map(g => (
<li key={g.id}>
<GuideCard guide={g} />
</li>
))}
</ul>
</section>
)}
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
{status === 'loading' && <Loader2 className="w-3.5 h-3.5 animate-spin text-content-faint" />}
</div>
{status === 'error' && <p className="text-body text-content-muted">{t('help.center.searchError')}</p>}
{docHits.length > 0 && (
<ul className="flex flex-col gap-1">
{docHits.map(hit => (
<li key={`${hit.slug}#${hit.anchor ?? ''}`}>
<HelpDocLink
to={`/help/${hit.slug}${hit.anchor ? `#${hit.anchor}` : ''}`}
title={hit.heading ? `${hit.title} · ${hit.heading}` : hit.title}
subtitle={hit.snippet}
/>
</li>
))}
</ul>
)}
</section>
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
<SearchX className="w-6 h-6 text-content-faint" />
<p className="text-body text-content-muted">{t('help.center.searchEmpty', { query: query.trim() })}</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,16 @@
import React from 'react'
import { Zap } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { HelpGuideSize } from '../../help/types'
import HelpBadge from './HelpBadge'
/** "Quick", "Guide" or "Walkthrough": how much of a task the guide covers. */
export default function HelpSizeChip({ size }: { size: HelpGuideSize }): React.ReactElement {
const { t } = useTranslation()
const label = size === 'quick' ? t('help.center.quick') : size === 'tour' ? t('help.center.tour') : t('help.center.guide')
return (
<HelpBadge tone="accent" icon={Zap}>
{label}
</HelpBadge>
)
}
+94
View File
@@ -0,0 +1,94 @@
/* Help center: the floating panel and its lightbox. Colors and sizes come from
the appearance tokens; this file only holds motion and the few shapes
Tailwind cannot express tidily. */
@keyframes trek-help-in {
from { opacity: 0; transform: translateY(14px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes trek-help-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(0.985); }
}
@keyframes trek-help-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes trek-help-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes trek-help-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes trek-help-pop {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.trek-help-backdrop[data-state='open'] {
animation: trek-help-fade 180ms ease-out;
}
.trek-help-backdrop[data-state='closing'] {
animation: trek-help-fade-out 160ms ease-in forwards;
}
.trek-help-backdrop[data-state='open'] > .trek-help-dialog {
animation: trek-help-in 280ms var(--ease-out-quint);
}
.trek-help-backdrop[data-state='closing'] > .trek-help-dialog {
animation: trek-help-out 160ms ease-in forwards;
}
.trek-help-menu {
animation: trek-help-pop 160ms var(--ease-out-quint);
transform-origin: top center;
}
/* Views slide up softly when the reader moves between home, search and a guide. */
.trek-help-view {
animation: trek-help-rise 220ms var(--ease-out-quint);
}
.trek-help-lightbox {
animation: trek-help-fade 160ms ease-out;
}
.trek-help-lightbox > .trek-help-lightbox-card {
animation: trek-help-pop 220ms var(--ease-out-quint);
}
/* Step pictures: a hairline frame so light screenshots keep an edge on light
surfaces, and a gentle lift on hover to say "click to enlarge". */
.trek-help-shot {
transition: transform 200ms var(--ease-out-quint), box-shadow 200ms var(--ease-out-quint);
}
.trek-help-shot:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-elevated);
}
/* A thin scrollbar inside the panel, matching the app's own. */
.trek-help-scroll {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
[data-reduce-motion] .trek-help-backdrop,
[data-reduce-motion] .trek-help-backdrop > .trek-help-dialog,
[data-reduce-motion] .trek-help-view,
[data-reduce-motion] .trek-help-menu,
[data-reduce-motion] .trek-help-lightbox,
[data-reduce-motion] .trek-help-lightbox > .trek-help-lightbox-card {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.trek-help-backdrop,
.trek-help-backdrop > .trek-help-dialog,
.trek-help-view,
.trek-help-menu,
.trek-help-lightbox,
.trek-help-lightbox > .trek-help-lightbox-card {
animation: none;
}
.trek-help-shot { transition: none; }
}
+89
View File
@@ -0,0 +1,89 @@
import {
Plus, Pencil, Image, Copy, Archive, Trash2, SlidersHorizontal, CalendarPlus, LayoutDashboard, Coins, Clock, Compass,
CalendarCheck, CircleDot, Hourglass, Calculator, CalendarRange, Building2, Flag, GraduationCap, CalendarOff, CalendarDays,
Repeat, UserPlus, Share2, Globe, MapPin, Search, Star, Eye, LandPlot, BarChart3, MinusCircle, Landmark,
DownloadCloud, CheckCircle2, PanelRight, Tags, Upload, FileInput, Library, Bookmark, BookOpen, Sparkles, ThumbsUp, Images, Link,
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, ListChecks, Wallet, FolderOpen, MessagesSquare,
type LucideIcon,
} from 'lucide-react'
import type { HelpIcon } from '../../help/types'
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
const ICONS: Record<HelpIcon, LucideIcon> = {
plus: Plus,
pencil: Pencil,
image: Image,
copy: Copy,
archive: Archive,
trash: Trash2,
filter: SlidersHorizontal,
calendar: CalendarPlus,
layout: LayoutDashboard,
coins: Coins,
clock: Clock,
compass: Compass,
calendarCheck: CalendarCheck,
halfDay: CircleDot,
hourglass: Hourglass,
calculator: Calculator,
calendarRange: CalendarRange,
building: Building2,
flag: Flag,
graduationCap: GraduationCap,
calendarOff: CalendarOff,
calendarDays: CalendarDays,
repeat: Repeat,
userPlus: UserPlus,
share: Share2,
globe: Globe,
mapPin: MapPin,
search: Search,
star: Star,
eye: Eye,
regions: LandPlot,
chart: BarChart3,
minusCircle: MinusCircle,
landmark: Landmark,
download: DownloadCloud,
checkCircle: CheckCircle2,
panelRight: PanelRight,
tags: Tags,
upload: Upload,
fileInput: FileInput,
library: Library,
bookmark: Bookmark,
bookOpen: BookOpen,
sparkles: Sparkles,
thumbsUp: ThumbsUp,
gallery: Images,
link: Link,
book: Book,
sliders: SlidersHorizontal,
palette: Palette,
map: Map,
bell: Bell,
plug: Plug,
cloudOff: CloudOff,
user: User,
lock: Lock,
shield: ShieldCheck,
fingerprint: Fingerprint,
key: KeyRound,
route: Route,
users: Users,
userRound: UserRound,
crown: Crown,
panelLeft: PanelLeftClose,
undo: Undo2,
ticket: Ticket,
train: Train,
listChecks: ListChecks,
wallet: Wallet,
folder: FolderOpen,
messages: MessagesSquare,
}
export function helpIcon(name: HelpIcon): LucideIcon {
return ICONS[name] ?? Compass
}
@@ -0,0 +1,14 @@
import { useLocation } from 'react-router'
/**
* A screen's route ready to follow. A trip screen's route carries `:id`, which
* only the address of the trip the reader is on can fill in; browsed from
* elsewhere, such a screen has no way in and the button stays away.
*/
export function useScreenRoute(route: string | undefined): string | null {
const { pathname } = useLocation()
if (!route) return null
if (!route.includes(':id')) return route
const match = /^\/trips\/(\d+)/.exec(pathname)
return match ? route.replace(':id', match[1]) : null
}