Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion services/platform/app/components/user-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock('@/lib/i18n/client', () => ({
const translations: Record<string, string> = {
// auth.userButton.*
'userButton.defaultName': 'User',
'userButton.helpLearning': 'Help & learning center',
'userButton.helpFeedback': 'Help & feedback',
'userButton.logOut': 'Log out',
'userButton.manageAccount': 'Manage account',
Expand Down Expand Up @@ -344,7 +345,11 @@ describe('UserButton', () => {
within(menu).getByRole('menuitem', { name: 'Language' }),
).toBeInTheDocument();

// Session items: asserted present, never activated.
// Session items: asserted present, never activated. The in-app learning
// center sits alongside the external feedback link.
expect(
within(menu).getByRole('menuitem', { name: 'Help & learning center' }),
).toBeInTheDocument();
expect(
within(menu).getByRole('menuitem', { name: 'Help & feedback' }),
).toBeInTheDocument();
Expand Down
10 changes: 10 additions & 0 deletions services/platform/app/components/user-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Link, useNavigate, useParams } from '@tanstack/react-router';
import {
LogOut,
HelpCircle,
GraduationCap,
Monitor,
Sun,
Moon,
Expand Down Expand Up @@ -550,6 +551,15 @@ export function UserButton({
});
}
helpGroup.push(
{
type: 'item',
label: t('userButton.helpLearning'),
icon: GraduationCap,
onClick: () => {
void navigate({ to: '/dashboard/help' });
},
className: 'py-2.5',
},
{
type: 'item',
label: t('userButton.helpFeedback'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';

import { checkAccessibility } from '@/tests/utils/a11y';
import { render, screen } from '@/tests/utils/render';

import { LessonVideo } from './lesson-video';

describe('LessonVideo', () => {
it('renders a "coming soon" placeholder when no video is configured', () => {
render(<LessonVideo lessonTitle="What is a large language model?" />);
// The placeholder copy comes from the real `help.video.unavailable` string.
expect(screen.getByRole('note')).toBeInTheDocument();
expect(screen.queryByText(/coming soon/i)).toBeInTheDocument();
// No <video> element is mounted in the placeholder branch.
expect(document.querySelector('video')).toBeNull();
});

it('renders a self-hosted video element when a source is provided', () => {
render(
<LessonVideo
videoSrc="/help-videos/intro.mp4"
lessonTitle="What is a large language model?"
/>,
);
const video = document.querySelector('video');
expect(video).not.toBeNull();
expect(video?.querySelector('source')?.getAttribute('src')).toBe(
'/help-videos/intro.mp4',
);
// Labelled for assistive tech via the lesson title.
expect(video?.getAttribute('aria-label')).toContain(
'What is a large language model?',
);
});

it('placeholder passes the axe audit', async () => {
const { container } = render(
<LessonVideo lessonTitle="Limitations and hallucinations" />,
);
await checkAccessibility(container);
});
});
63 changes: 63 additions & 0 deletions services/platform/app/features/help/components/lesson-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { VStack } from '@tale/ui/layout';
import { Text } from '@tale/ui/text';
import { Video } from 'lucide-react';

import { useT } from '@/lib/i18n/client';

interface LessonVideoProps {
/**
* Same-origin URL of a self-hosted walkthrough clip, or `undefined` when no
* clip has been produced yet. External (cross-origin) sources are
* intentionally unsupported — the platform's CSP pins `media-src` to `'self'`
* for offline / self-hosted deployments.
*/
videoSrc?: string;
/** Accessible title of the lesson, used to label the video region. */
lessonTitle: string;
}

/**
* Renders a lesson's video walkthrough.
*
* When `videoSrc` is set it plays a self-hosted clip with native controls;
* otherwise it shows an accessible "coming soon" placeholder so the lesson
* still reads as a structured learning unit. Keeping the embed self-hosted
* (rather than a YouTube/Vimeo iframe) is the deliberate choice for this
* product: the deployment CSP blocks third-party frames and media, and many
* installs run fully offline.
*/
export function LessonVideo({ videoSrc, lessonTitle }: LessonVideoProps) {
const { t } = useT('help');

if (!videoSrc) {
return (
<div
role="note"
className="border-border-base bg-bg-elevated/40 flex aspect-video w-full items-center justify-center rounded-lg border"
>
<VStack gap={2} align="center" className="max-w-sm px-6 text-center">
<Video aria-hidden className="text-fg-muted size-8" />
<Text variant="muted" className="text-sm">
{t('video.unavailable')}
</Text>
</VStack>
</div>
);
}

return (
<video
controls
preload="metadata"
aria-label={t('video.label', { title: lessonTitle })}
className="border-border-base aspect-video w-full rounded-lg border bg-black"
>
<source src={videoSrc} />
{/* Self-hosted captions ship alongside each clip as a sibling WebVTT
file, so a same-origin `.vtt` next to the video satisfies both the
caption requirement and the `media-src 'self'` CSP. */}
<track kind="captions" src={`${videoSrc}.vtt`} default />
{t('video.unsupported')}
</video>
);
}
57 changes: 57 additions & 0 deletions services/platform/app/features/help/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';

import enMessages from '../../../messages/en.json';
import { ALL_LESSON_IDS, HELP_CATEGORIES, findLesson } from './content';

// The curriculum table addresses its copy through dynamic i18n keys
// (`help.categories.<id>.*`, `help.lessons.<id>.*`), which the orphan-key
// scanner can't follow — so these tests are the guard that every id in the
// table actually resolves to a real message. A typo'd id would otherwise
// render a raw key in the UI and pass every other check.
const help = enMessages.help as {
categories: Record<string, { title: string; description: string }>;
lessons: Record<string, { title: string; summary: string; body: string }>;
};

describe('help curriculum', () => {
it('every category id maps to a localized title and description', () => {
for (const category of HELP_CATEGORIES) {
expect(help.categories[category.id]?.title).toBeTruthy();
expect(help.categories[category.id]?.description).toBeTruthy();
}
});

it('every lesson id maps to a localized title, summary, and body', () => {
for (const category of HELP_CATEGORIES) {
for (const lesson of category.lessons) {
const copy = help.lessons[lesson.id];
expect(copy?.title).toBeTruthy();
expect(copy?.summary).toBeTruthy();
expect(copy?.body).toBeTruthy();
}
}
});

it('exposes a flat lesson-id list with no duplicates', () => {
const unique = new Set(ALL_LESSON_IDS);
expect(unique.size).toBe(ALL_LESSON_IDS.length);
expect(ALL_LESSON_IDS.length).toBeGreaterThan(0);
});

it('covers the three pillars from issue #1922', () => {
const ids = HELP_CATEGORIES.map((c) => c.id);
expect(ids).toEqual(['fundamentals', 'platform', 'responsibleAi']);
});

describe('findLesson', () => {
it('resolves a known lesson to its category', () => {
const result = findLesson('whatAreLlms');
expect(result?.category.id).toBe('fundamentals');
expect(result?.lesson.id).toBe('whatAreLlms');
});

it('returns null for an unknown id', () => {
expect(findLesson('does-not-exist')).toBeNull();
});
});
});
99 changes: 99 additions & 0 deletions services/platform/app/features/help/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
BookOpen,
GraduationCap,
ShieldCheck,
type LucideIcon,
} from 'lucide-react';

/**
* Curriculum for the in-app Help & learning center (`/dashboard/help`).
*
* Only the *structure* lives here — ids, ordering, icons, reading time, and the
* optional self-hosted video source. All human-facing text (category titles,
* lesson titles/summaries, and the markdown body) is resolved at render time
* from the `help` i18n namespace so the curriculum stays fully localized:
*
* help.categories.<categoryId>.{title,description}
* help.lessons.<lessonId>.{title,summary,body}
*
* Those keys are looked up dynamically, so the `help.categories` and
* `help.lessons` prefixes are listed in `lib/i18n/keys-dynamic.txt` for the
* orphan-key scanner.
*
* Video posture: the platform ships a strict CSP (`media-src 'self'`,
* `frame-src 'self'`) for its offline / self-hosted deployments, so external
* embeds (YouTube, Vimeo, …) are intentionally blocked. A lesson's `videoSrc`
* must therefore be a **same-origin** path to a self-hosted clip (e.g. a file
* under `services/platform/public/`). Lessons without a `videoSrc` render a
* "coming soon" placeholder instead — see `LessonVideo`.
*/
export interface HelpLesson {
/** Stable id; the i18n key segment under `help.lessons.<id>`. */
id: string;
/**
* Optional same-origin URL of a self-hosted walkthrough clip. Omit until a
* clip has been produced; the lesson then renders a placeholder. Must be
* served from the app's own origin to satisfy the `media-src 'self'` CSP.
*/
videoSrc?: string;
/** Estimated minutes to read / watch — shown as a hint next to the title. */
durationMinutes: number;
}

export interface HelpCategory {
/** Stable id; the i18n key segment under `help.categories.<id>`. */
id: string;
icon: LucideIcon;
lessons: HelpLesson[];
}

/**
* The three pillars from issue #1922: LLM fundamentals, platform how-tos, and
* the opportunities & risks of AI (responsible-use guidance).
*/
export const HELP_CATEGORIES: readonly HelpCategory[] = [
{
id: 'fundamentals',
icon: GraduationCap,
lessons: [
{ id: 'whatAreLlms', durationMinutes: 5 },
{ id: 'howLlmsWork', durationMinutes: 6 },
{ id: 'limitations', durationMinutes: 5 },
],
},
{
id: 'platform',
icon: BookOpen,
lessons: [
{ id: 'gettingStarted', durationMinutes: 4 },
{ id: 'chattingWithAgents', durationMinutes: 5 },
{ id: 'knowledgeBase', durationMinutes: 5 },
{ id: 'automations', durationMinutes: 6 },
],
},
{
id: 'responsibleAi',
icon: ShieldCheck,
lessons: [
{ id: 'opportunities', durationMinutes: 5 },
{ id: 'risks', durationMinutes: 6 },
{ id: 'bestPractices', durationMinutes: 5 },
],
},
];

/** Flat, ordered list of every lesson id — handy for default selection. */
export const ALL_LESSON_IDS: readonly string[] = HELP_CATEGORIES.flatMap((c) =>
c.lessons.map((l) => l.id),
);

/** Look up the `{ category, lesson }` pair for a lesson id, or `null`. */
export function findLesson(
lessonId: string,
): { category: HelpCategory; lesson: HelpLesson } | null {
for (const category of HELP_CATEGORIES) {
const lesson = category.lessons.find((l) => l.id === lessonId);
if (lesson) return { category, lesson };
}
return null;
}
Loading