From f7d1addc8e369d08afa79f7901a2c47b3cd641f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivett=20=C3=96rd=C3=B6g?= Date: Wed, 8 Apr 2026 13:45:54 +0200 Subject: [PATCH 1/3] Add optional video field to pattern frontmatter Patterns, anti-patterns, and obstacles can now declare a single video URL in their YAML frontmatter, rendered on the detail page as a "Watch video" link that opens in a new tab. YouTube timestamps live in the URL itself (e.g. `?t=412s`), so no separate schema is needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/frontmatter.md | 10 ++++++ website/app/[category]/[slug]/page.tsx | 11 ++++++ website/lib/markdown.ts | 1 + website/lib/types.ts | 1 + website/tests/unit/components/pages.test.tsx | 27 +++++++++++++++ website/tests/unit/markdown.test.ts | 36 ++++++++++++++++++++ 6 files changed, 86 insertions(+) diff --git a/specs/frontmatter.md b/specs/frontmatter.md index c1c7135..8fcbd95 100644 --- a/specs/frontmatter.md +++ b/specs/frontmatter.md @@ -33,3 +33,13 @@ Optional. Array of alternate terms or names for the concept. Unlike `alternative Frontend support: display only. Parsed in `lib/markdown.ts` and displayed as "Synonyms: ..." on the detail page. No URL redirects or `generateStaticParams` changes. Added by Steve Kuo in commit `1d6176c`, moved to frontmatter in `9029de6`. + +## video + +```yaml +video: https://www.youtube.com/watch?v=abc123&t=412 +``` + +Optional. A single URL pointing to a video explaining the pattern. YouTube timestamps can be embedded directly in the URL (e.g. `&t=412` or `?t=6m52s`). + +Frontend support: parsed in `lib/markdown.ts` and rendered on the detail page as a "Watch video" link that opens in a new tab. diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index 5f2448d..5554f7a 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -135,6 +135,17 @@ export default async function PatternPage({ params }: PatternPageProps) { Synonyms: {pattern.synonyms.join(', ')}

)} + {pattern.video && ( +

+ + Watch video + +

+ )} diff --git a/website/lib/markdown.ts b/website/lib/markdown.ts index b3876e2..a1e94c2 100644 --- a/website/lib/markdown.ts +++ b/website/lib/markdown.ts @@ -179,6 +179,7 @@ export function getPatternBySlug( ...(data.authors && { authors: data.authors }), ...(data.alternative_titles && { alternativeTitles: data.alternative_titles }), ...(data.synonyms && { synonyms: data.synonyms }), + ...(data.video && { video: data.video }), ...(mergedPatterns.length > 0 && { relatedPatterns: mergedPatterns }), ...(mergedAntiPatterns.length > 0 && { relatedAntiPatterns: mergedAntiPatterns }), ...(mergedObstacles.length > 0 && { relatedObstacles: mergedObstacles }), diff --git a/website/lib/types.ts b/website/lib/types.ts index f38028d..66df8bb 100644 --- a/website/lib/types.ts +++ b/website/lib/types.ts @@ -20,6 +20,7 @@ export interface PatternMetadata { authors?: string[] alternativeTitles?: string[] synonyms?: string[] + video?: string relatedPatterns?: RelatedPattern[] relatedAntiPatterns?: RelatedPattern[] relatedObstacles?: RelatedPattern[] diff --git a/website/tests/unit/components/pages.test.tsx b/website/tests/unit/components/pages.test.tsx index bf5237e..9306744 100644 --- a/website/tests/unit/components/pages.test.tsx +++ b/website/tests/unit/components/pages.test.tsx @@ -471,6 +471,33 @@ describe('Pattern Detail Page', () => { }) }) + describe('Video Display', () => { + it('displays a Watch video link when video is present', async () => { + const videoUrl = 'https://www.youtube.com/watch?v=abc123&t=412' + const patternWithVideo = { + ...mockPattern, + video: videoUrl + } + mockGetPatternBySlug.mockReturnValue(patternWithVideo) + + const params = Promise.resolve({ category: 'patterns', slug: 'test-pattern-detail' }) + render(await PatternPage({ params })) + + const link = screen.getByRole('link', { name: /Watch video/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', videoUrl) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('does not display Watch video link when video is absent', async () => { + const params = Promise.resolve({ category: 'patterns', slug: 'test-pattern-detail' }) + render(await PatternPage({ params })) + + expect(screen.queryByRole('link', { name: /Watch video/i })).not.toBeInTheDocument() + }) + }) + describe('Pattern without emoji', () => { it('renders correctly when pattern has no emoji', async () => { const patternWithoutEmoji = { ...mockPattern, emojiIndicator: undefined } diff --git a/website/tests/unit/markdown.test.ts b/website/tests/unit/markdown.test.ts index 9160864..0743c61 100644 --- a/website/tests/unit/markdown.test.ts +++ b/website/tests/unit/markdown.test.ts @@ -453,6 +453,42 @@ AI defaults to silent compliance.` expect(pattern).toBeDefined() expect(pattern.alternativeTitles).toEqual(["Old Name"]) }) + + it('should extract video from frontmatter', () => { + const mockMarkdown = `--- +video: https://www.youtube.com/watch?v=abc123&t=412 +--- +# Active Partner + +## Problem +AI defaults to silent compliance.` + + mockedPath.join.mockReturnValue('/fake/path/documents/patterns/active-partner.md') + mockedFs.readFileSync.mockReturnValue(mockMarkdown) + + const pattern = getPatternBySlug('patterns', 'active-partner') + + expect(pattern).toBeDefined() + expect(pattern.video).toBe('https://www.youtube.com/watch?v=abc123&t=412') + }) + + it('should handle pattern without video', () => { + const mockMarkdown = `--- +authors: [lexler] +--- +# Active Partner + +## Problem +AI defaults to silent compliance.` + + mockedPath.join.mockReturnValue('/fake/path/documents/patterns/active-partner.md') + mockedFs.readFileSync.mockReturnValue(mockMarkdown) + + const pattern = getPatternBySlug('patterns', 'active-partner') + + expect(pattern).toBeDefined() + expect(pattern.video).toBeUndefined() + }) }) describe('getAllPatterns', () => { From 98c7a325e067285496af0b10c351780635ca0838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivett=20=C3=96rd=C3=B6g?= Date: Wed, 8 Apr 2026 13:46:04 +0200 Subject: [PATCH 2/3] Link YouTube videos to 42 patterns from three talks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds video frontmatter to patterns explained in: - Lada Kesseler's deep-dive talk (37 entries: 21 patterns, 10 obstacles, 6 anti-patterns) - Ivett Ördög's Habit Hooks talk (4 entries: habit-hooks, constrained-tests, approved-scenarios, approved-logs) - Ivett Ördög's legacy migration story (1 entry: show-the-agent) Each timestamp anchors to the point where a viewer can start watching and get enough background to understand the pattern — usually before the term itself is introduced, when the motivating story or foundational concepts begin. Co-Authored-By: Claude Opus 4.6 (1M context) --- documents/anti-patterns/answer-injection.md | 1 + documents/anti-patterns/distracted-agent.md | 1 + documents/anti-patterns/perfect-recall-fallacy.md | 1 + documents/anti-patterns/silent-misalignment.md | 1 + documents/anti-patterns/tell-me-a-lie.md | 1 + documents/anti-patterns/unvalidated-leaps.md | 1 + documents/obstacles/black-box-ai.md | 1 + documents/obstacles/cannot-learn.md | 1 + documents/obstacles/compliance-bias.md | 1 + documents/obstacles/context-rot.md | 1 + documents/obstacles/degrades-under-complexity.md | 1 + documents/obstacles/excess-verbosity.md | 1 + documents/obstacles/hallucinations.md | 1 + documents/obstacles/limited-context-window.md | 1 + documents/obstacles/limited-focus.md | 1 + documents/obstacles/non-determinism.md | 1 + documents/patterns/active-partner.md | 1 + documents/patterns/approved-logs.md | 1 + documents/patterns/approved-scenarios.md | 1 + documents/patterns/chain-of-small-steps.md | 1 + documents/patterns/check-alignment.md | 1 + documents/patterns/constrained-tests.md | 1 + documents/patterns/context-management.md | 1 + documents/patterns/context-markers.md | 1 + documents/patterns/extract-knowledge.md | 1 + documents/patterns/focused-agent.md | 1 + documents/patterns/ground-rules.md | 1 + documents/patterns/habit-hooks.md | 1 + documents/patterns/hooks.md | 1 + documents/patterns/knowledge-checkpoint.md | 1 + documents/patterns/knowledge-composition.md | 1 + documents/patterns/knowledge-document.md | 1 + documents/patterns/noise-cancellation.md | 1 + documents/patterns/offload-deterministic.md | 1 + documents/patterns/parallel-implementations.md | 1 + documents/patterns/playgrounds.md | 1 + documents/patterns/reference-docs.md | 1 + documents/patterns/reminders.md | 1 + documents/patterns/reverse-direction.md | 1 + documents/patterns/semantic-zoom.md | 1 + documents/patterns/show-the-agent-let-it-repeat-automate.md | 1 + documents/patterns/text-native.md | 1 + 42 files changed, 42 insertions(+) diff --git a/documents/anti-patterns/answer-injection.md b/documents/anti-patterns/answer-injection.md index 7dbc509..92fc9e9 100644 --- a/documents/anti-patterns/answer-injection.md +++ b/documents/anti-patterns/answer-injection.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5752s --- # Answer Injection (Anti-pattern) diff --git a/documents/anti-patterns/distracted-agent.md b/documents/anti-patterns/distracted-agent.md index 35e8142..a332aba 100644 --- a/documents/anti-patterns/distracted-agent.md +++ b/documents/anti-patterns/distracted-agent.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=836s --- # Distracted Agent (Anti-pattern) diff --git a/documents/anti-patterns/perfect-recall-fallacy.md b/documents/anti-patterns/perfect-recall-fallacy.md index e474318..a2258c2 100644 --- a/documents/anti-patterns/perfect-recall-fallacy.md +++ b/documents/anti-patterns/perfect-recall-fallacy.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3135s --- # Perfect Recall Fallacy (Anti-pattern) diff --git a/documents/anti-patterns/silent-misalignment.md b/documents/anti-patterns/silent-misalignment.md index 04ac29c..b02363d 100644 --- a/documents/anti-patterns/silent-misalignment.md +++ b/documents/anti-patterns/silent-misalignment.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5142s --- # Silent Misalignment (Anti-pattern) diff --git a/documents/anti-patterns/tell-me-a-lie.md b/documents/anti-patterns/tell-me-a-lie.md index 604e860..0b8e6f6 100644 --- a/documents/anti-patterns/tell-me-a-lie.md +++ b/documents/anti-patterns/tell-me-a-lie.md @@ -1,5 +1,6 @@ --- authors: [llewellyn_falco] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6015s --- # Tell Me a Lie (Anti-pattern) diff --git a/documents/anti-patterns/unvalidated-leaps.md b/documents/anti-patterns/unvalidated-leaps.md index 99a9ef2..188b116 100644 --- a/documents/anti-patterns/unvalidated-leaps.md +++ b/documents/anti-patterns/unvalidated-leaps.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3345s --- # Unvalidated Leaps (Anti-pattern) diff --git a/documents/obstacles/black-box-ai.md b/documents/obstacles/black-box-ai.md index d948824..d8f1007 100644 --- a/documents/obstacles/black-box-ai.md +++ b/documents/obstacles/black-box-ai.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5055s --- # Black Box AI (Obstacle) diff --git a/documents/obstacles/cannot-learn.md b/documents/obstacles/cannot-learn.md index 4b0e4e9..9e24ece 100644 --- a/documents/obstacles/cannot-learn.md +++ b/documents/obstacles/cannot-learn.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=300s --- # Cannot Learn (Obstacle) diff --git a/documents/obstacles/compliance-bias.md b/documents/obstacles/compliance-bias.md index 403326b..66e32f2 100644 --- a/documents/obstacles/compliance-bias.md +++ b/documents/obstacles/compliance-bias.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5108s --- # Compliance Bias (Obstacle) diff --git a/documents/obstacles/context-rot.md b/documents/obstacles/context-rot.md index 5bcec8b..6f1ce0c 100644 --- a/documents/obstacles/context-rot.md +++ b/documents/obstacles/context-rot.md @@ -1,6 +1,7 @@ --- authors: [lada_kesseler, steve_kuo] synonyms: [Dementia] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=352s --- # Context Rot (Obstacle) diff --git a/documents/obstacles/degrades-under-complexity.md b/documents/obstacles/degrades-under-complexity.md index b8d9264..c8b23e6 100644 --- a/documents/obstacles/degrades-under-complexity.md +++ b/documents/obstacles/degrades-under-complexity.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3550s --- # Degrades Under Complexity (Obstacle) diff --git a/documents/obstacles/excess-verbosity.md b/documents/obstacles/excess-verbosity.md index ababec3..e8ac9e9 100644 --- a/documents/obstacles/excess-verbosity.md +++ b/documents/obstacles/excess-verbosity.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1368s --- # Excess Verbosity (Obstacle) diff --git a/documents/obstacles/hallucinations.md b/documents/obstacles/hallucinations.md index bc67da6..0826fec 100644 --- a/documents/obstacles/hallucinations.md +++ b/documents/obstacles/hallucinations.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3080s --- # Hallucinations (Obstacle) diff --git a/documents/obstacles/limited-context-window.md b/documents/obstacles/limited-context-window.md index 5eb95b1..93f266d 100644 --- a/documents/obstacles/limited-context-window.md +++ b/documents/obstacles/limited-context-window.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=775s --- # Limited Context Window (Obstacle) diff --git a/documents/obstacles/limited-focus.md b/documents/obstacles/limited-focus.md index 312d6ab..15d99df 100644 --- a/documents/obstacles/limited-focus.md +++ b/documents/obstacles/limited-focus.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=968s --- # Limited Focus (Obstacle) diff --git a/documents/obstacles/non-determinism.md b/documents/obstacles/non-determinism.md index 6dec9f6..d2e2405 100644 --- a/documents/obstacles/non-determinism.md +++ b/documents/obstacles/non-determinism.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2485s --- # Non-Determinism (Obstacle) diff --git a/documents/patterns/active-partner.md b/documents/patterns/active-partner.md index 3042fe6..e1e9563 100644 --- a/documents/patterns/active-partner.md +++ b/documents/patterns/active-partner.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5313s --- # Active Partner diff --git a/documents/patterns/approved-logs.md b/documents/patterns/approved-logs.md index 4dffa15..f775779 100644 --- a/documents/patterns/approved-logs.md +++ b/documents/patterns/approved-logs.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=2547s --- # Approved Logs diff --git a/documents/patterns/approved-scenarios.md b/documents/patterns/approved-scenarios.md index 87ecf88..83764c3 100644 --- a/documents/patterns/approved-scenarios.md +++ b/documents/patterns/approved-scenarios.md @@ -1,6 +1,7 @@ --- authors: [ivett_ordog] alternative_titles: ["Approved Fixtures"] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=2250s --- # Approved Scenarios diff --git a/documents/patterns/chain-of-small-steps.md b/documents/patterns/chain-of-small-steps.md index f5c85b0..2f4d0b8 100644 --- a/documents/patterns/chain-of-small-steps.md +++ b/documents/patterns/chain-of-small-steps.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3575s --- # Chain of Small Steps diff --git a/documents/patterns/check-alignment.md b/documents/patterns/check-alignment.md index 67b6567..5ae8d0e 100644 --- a/documents/patterns/check-alignment.md +++ b/documents/patterns/check-alignment.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5448s --- # Check Alignment diff --git a/documents/patterns/constrained-tests.md b/documents/patterns/constrained-tests.md index 997d210..b00824b 100644 --- a/documents/patterns/constrained-tests.md +++ b/documents/patterns/constrained-tests.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=1578s --- # Constrained Tests diff --git a/documents/patterns/context-management.md b/documents/patterns/context-management.md index 64b95e4..4d0cae3 100644 --- a/documents/patterns/context-management.md +++ b/documents/patterns/context-management.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=352s --- # Context Management diff --git a/documents/patterns/context-markers.md b/documents/patterns/context-markers.md index ab751eb..3eca4bc 100644 --- a/documents/patterns/context-markers.md +++ b/documents/patterns/context-markers.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5568s --- # Context Markers diff --git a/documents/patterns/extract-knowledge.md b/documents/patterns/extract-knowledge.md index eac9ae3..e159897 100644 --- a/documents/patterns/extract-knowledge.md +++ b/documents/patterns/extract-knowledge.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=691s --- # Extract Knowledge diff --git a/documents/patterns/focused-agent.md b/documents/patterns/focused-agent.md index cff3845..8d4059f 100644 --- a/documents/patterns/focused-agent.md +++ b/documents/patterns/focused-agent.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=975s --- # Focused Agent diff --git a/documents/patterns/ground-rules.md b/documents/patterns/ground-rules.md index 9c4dfeb..61b9bf3 100644 --- a/documents/patterns/ground-rules.md +++ b/documents/patterns/ground-rules.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=635s --- # Ground Rules diff --git a/documents/patterns/habit-hooks.md b/documents/patterns/habit-hooks.md index cd667ef..17c84c7 100644 --- a/documents/patterns/habit-hooks.md +++ b/documents/patterns/habit-hooks.md @@ -1,5 +1,6 @@ --- authors: [ivett_ordog] +video: https://www.youtube.com/watch?v=GyI5qU9MNJU&t=775s --- # Habit Hooks diff --git a/documents/patterns/hooks.md b/documents/patterns/hooks.md index f064c4b..856b6c5 100644 --- a/documents/patterns/hooks.md +++ b/documents/patterns/hooks.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3865s --- # Hooks diff --git a/documents/patterns/knowledge-checkpoint.md b/documents/patterns/knowledge-checkpoint.md index d2a7178..e70079f 100644 --- a/documents/patterns/knowledge-checkpoint.md +++ b/documents/patterns/knowledge-checkpoint.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2495s --- # Knowledge Checkpoint diff --git a/documents/patterns/knowledge-composition.md b/documents/patterns/knowledge-composition.md index 85ed857..26a519a 100644 --- a/documents/patterns/knowledge-composition.md +++ b/documents/patterns/knowledge-composition.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1247s --- # Knowledge Composition diff --git a/documents/patterns/knowledge-document.md b/documents/patterns/knowledge-document.md index d8cf51a..d4c02a5 100644 --- a/documents/patterns/knowledge-document.md +++ b/documents/patterns/knowledge-document.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=545s --- # Knowledge Document diff --git a/documents/patterns/noise-cancellation.md b/documents/patterns/noise-cancellation.md index 9913d36..c8bd69f 100644 --- a/documents/patterns/noise-cancellation.md +++ b/documents/patterns/noise-cancellation.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1461s --- # Noise Cancellation diff --git a/documents/patterns/offload-deterministic.md b/documents/patterns/offload-deterministic.md index 7473190..0196381 100644 --- a/documents/patterns/offload-deterministic.md +++ b/documents/patterns/offload-deterministic.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2917s --- # Offload Deterministic diff --git a/documents/patterns/parallel-implementations.md b/documents/patterns/parallel-implementations.md index 070f1b0..df6f2f5 100644 --- a/documents/patterns/parallel-implementations.md +++ b/documents/patterns/parallel-implementations.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=2671s --- # Parallel Implementations diff --git a/documents/patterns/playgrounds.md b/documents/patterns/playgrounds.md index dc16c63..1461ca1 100644 --- a/documents/patterns/playgrounds.md +++ b/documents/patterns/playgrounds.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=3135s --- # Playgrounds diff --git a/documents/patterns/reference-docs.md b/documents/patterns/reference-docs.md index 9470915..86814ca 100644 --- a/documents/patterns/reference-docs.md +++ b/documents/patterns/reference-docs.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1136s --- # Reference Docs diff --git a/documents/patterns/reminders.md b/documents/patterns/reminders.md index 5941d41..dd02733 100644 --- a/documents/patterns/reminders.md +++ b/documents/patterns/reminders.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=4163s --- # Reminders diff --git a/documents/patterns/reverse-direction.md b/documents/patterns/reverse-direction.md index 78f2b4b..2db8b7c 100644 --- a/documents/patterns/reverse-direction.md +++ b/documents/patterns/reverse-direction.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6066s --- # Reverse Direction diff --git a/documents/patterns/semantic-zoom.md b/documents/patterns/semantic-zoom.md index 8ee85e7..b4dc270 100644 --- a/documents/patterns/semantic-zoom.md +++ b/documents/patterns/semantic-zoom.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=1388s --- # Semantic Zoom diff --git a/documents/patterns/show-the-agent-let-it-repeat-automate.md b/documents/patterns/show-the-agent-let-it-repeat-automate.md index 2bc1f07..864cda1 100644 --- a/documents/patterns/show-the-agent-let-it-repeat-automate.md +++ b/documents/patterns/show-the-agent-let-it-repeat-automate.md @@ -1,6 +1,7 @@ --- authors: [ivett_ordog] alternative_titles: ["Show me, I will repeat-automate"] +video: https://www.youtube.com/watch?v=9dyGJ2cg8p4&t=258s --- # Show the Agent, Let it Repeat/Automate diff --git a/documents/patterns/text-native.md b/documents/patterns/text-native.md index 733488e..593011e 100644 --- a/documents/patterns/text-native.md +++ b/documents/patterns/text-native.md @@ -1,5 +1,6 @@ --- authors: [lada_kesseler] +video: https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=6265s --- # Text Native From ad715eceffb17ccb132bb2e895bc72f18a857b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivett=20=C3=96rd=C3=B6g?= Date: Wed, 8 Apr 2026 14:56:25 +0200 Subject: [PATCH 3/3] Render YouTube thumbnails on pattern pages Replace the plain "Watch video" link with a polished thumbnail card showing the YouTube preview image and the video title underneath, on both the pattern detail page and the complete catalog detail view. Also surface alternative titles and synonyms in the catalog detail. Video titles are sourced via YouTube oEmbed at edit time through a new scripts/fetch-video-titles.mjs script (npm run fetch:videos), which writes website/lib/video-titles.json so the build stays network-free and reproducible. --- CONTRIBUTE.md | 12 ++ scripts/fetch-video-titles.mjs | 121 ++++++++++++++++++ website/app/[category]/[slug]/page.tsx | 59 ++++----- .../app/components/VideoThumbnail.module.css | 112 ++++++++++++++++ website/app/components/VideoThumbnail.tsx | 50 ++++++++ website/app/pattern-catalog/CatalogView.tsx | 44 +++++-- website/app/pattern-catalog/page.module.css | 27 +++- website/app/pattern-catalog/page.tsx | 4 + website/app/pattern-catalog/types.ts | 4 + website/app/pattern-detail.module.css | 27 +++- website/lib/markdown.ts | 2 + website/lib/types.ts | 1 + website/lib/video-titles.json | 5 + website/lib/video-titles.ts | 24 ++++ website/package.json | 1 + website/tests/unit/completeCatalog.test.tsx | 4 +- website/tests/unit/components/pages.test.tsx | 11 +- 17 files changed, 462 insertions(+), 46 deletions(-) create mode 100644 scripts/fetch-video-titles.mjs create mode 100644 website/app/components/VideoThumbnail.module.css create mode 100644 website/app/components/VideoThumbnail.tsx create mode 100644 website/lib/video-titles.json create mode 100644 website/lib/video-titles.ts diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 9b9fe15..a8b3f8f 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -18,9 +18,21 @@ This guide explains how to contribute patterns, anti-patterns, or obstacles to t ```yaml --- authors: [author_id] +video: https://www.youtube.com/watch?v=VIDEO_ID&t=300s # optional --- ``` +The `video` field is optional. When present, the pattern page renders a YouTube +thumbnail with the video's title underneath. After adding or changing a `video` +URL, regenerate the cached video titles: + +```bash +cd website && npm run fetch:videos +``` + +This updates `website/lib/video-titles.json` (commit it alongside the markdown +change). + **Relationships:** Define in `documents/relationships.mmd` using Mermaid graph syntax: ```mermaid patterns/your-pattern -->|solves| obstacles/some-obstacle diff --git a/scripts/fetch-video-titles.mjs b/scripts/fetch-video-titles.mjs new file mode 100644 index 0000000..a877479 --- /dev/null +++ b/scripts/fetch-video-titles.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Scans documents/**/*.md frontmatter for `video:` URLs, fetches video titles +// from YouTube's public oEmbed endpoint, and writes the result to +// website/lib/video-titles.json. The JSON maps a YouTube video id to its title. +// +// Run manually after adding or updating a video link: +// npm run fetch:videos +// +// Existing entries are preserved on network failure so the build remains +// reproducible offline. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, '..'); +const DOCS_DIR = path.join(REPO_ROOT, 'documents'); +const OUTPUT_PATH = path.join(REPO_ROOT, 'website', 'lib', 'video-titles.json'); + +function getYouTubeId(url) { + try { + const parsed = new URL(url); + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1) || null; + } + if (parsed.hostname.endsWith('youtube.com')) { + return parsed.searchParams.get('v'); + } + } catch { + return null; + } + return null; +} + +function extractVideoUrl(fileContents) { + // Match `video:` line in frontmatter, capture the URL + const match = fileContents.match(/^video:\s*(.+)$/m); + return match ? match[1].trim() : null; +} + +function collectVideoIds() { + const ids = new Set(); + const categories = ['patterns', 'anti-patterns', 'obstacles']; + for (const category of categories) { + const dir = path.join(DOCS_DIR, category); + if (!fs.existsSync(dir)) continue; + for (const filename of fs.readdirSync(dir)) { + if (!filename.endsWith('.md')) continue; + const contents = fs.readFileSync(path.join(dir, filename), 'utf-8'); + const url = extractVideoUrl(contents); + if (!url) continue; + const id = getYouTubeId(url); + if (id) ids.add(id); + } + } + return Array.from(ids); +} + +async function fetchTitle(videoId) { + // Use the canonical watch URL (no timestamp) for oEmbed lookup + const watchUrl = `https://www.youtube.com/watch?v=${videoId}`; + const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`; + const res = await fetch(oembedUrl); + if (!res.ok) { + throw new Error(`oEmbed ${res.status} for ${videoId}`); + } + const data = await res.json(); + return data.title; +} + +function loadExisting() { + try { + return JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf-8')); + } catch { + return {}; + } +} + +async function main() { + const ids = collectVideoIds(); + console.log(`Found ${ids.length} unique video id(s) in documents/`); + + const existing = loadExisting(); + const result = { ...existing }; + let fetched = 0; + let failed = 0; + + for (const id of ids) { + try { + const title = await fetchTitle(id); + result[id] = title; + fetched += 1; + console.log(` ${id} → ${title}`); + } catch (err) { + failed += 1; + const cached = existing[id]; + if (cached) { + console.warn(` ${id} fetch failed (${err.message}); keeping cached title: ${cached}`); + } else { + console.warn(` ${id} fetch failed (${err.message}); no cached value`); + } + } + } + + // Drop entries for video ids that are no longer referenced + for (const id of Object.keys(result)) { + if (!ids.includes(id)) { + delete result[id]; + } + } + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2) + '\n'); + console.log(`Wrote ${Object.keys(result).length} title(s) to ${path.relative(REPO_ROOT, OUTPUT_PATH)}`); + console.log(`Fetched: ${fetched}, failed: ${failed}`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index 5554f7a..ae345fd 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -8,6 +8,7 @@ import { basePath } from "@/lib/config"; import { PatternCategory } from "@/lib/types"; import Authors from "@/app/components/Authors"; import RelatedLinks from "@/app/components/RelatedLinks"; +import VideoThumbnail from "@/app/components/VideoThumbnail"; import styles from "../../pattern-detail.module.css"; interface PatternPageProps { @@ -119,38 +120,38 @@ export default async function PatternPage({ params }: PatternPageProps) {
-
- {pattern.emojiIndicator && ( -
{pattern.emojiIndicator}
- )} -
-

{pattern.title}

- {pattern.alternativeTitles && pattern.alternativeTitles.length > 0 && ( -

- Also known as: {pattern.alternativeTitles.join(', ')} -

- )} - {pattern.synonyms && pattern.synonyms.length > 0 && ( -

- Synonyms: {pattern.synonyms.join(', ')} -

- )} - {pattern.video && ( -

- - Watch video - -

+
+
+ {pattern.emojiIndicator && ( +
{pattern.emojiIndicator}
)} +
+

{pattern.title}

+ {pattern.alternativeTitles && pattern.alternativeTitles.length > 0 && ( +

+ Also known as: {pattern.alternativeTitles.join(', ')} +

+ )} + {pattern.synonyms && pattern.synonyms.length > 0 && ( +

+ Synonyms: {pattern.synonyms.join(', ')} +

+ )} +
+ + {config.label} +
- - {config.label} - + {pattern.video && ( +
+ +
+ )}
diff --git a/website/app/components/VideoThumbnail.module.css b/website/app/components/VideoThumbnail.module.css new file mode 100644 index 0000000..7b86eb6 --- /dev/null +++ b/website/app/components/VideoThumbnail.module.css @@ -0,0 +1,112 @@ +.thumbnail { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 240px; + text-decoration: none; + color: var(--color-text-secondary); +} + +.thumbnail:hover, +.thumbnail:focus-visible { + text-decoration: none; + color: var(--color-text-primary); +} + +.thumbnail:focus-visible { + outline: none; +} + +.imageWrapper { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: var(--border-radius); + overflow: hidden; + background-color: var(--color-surface); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + transition: transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.thumbnail:hover .imageWrapper, +.thumbnail:focus-visible .imageWrapper { + transform: translateY(-2px); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); +} + +.thumbnail:focus-visible .imageWrapper { + outline: 2px solid var(--color-accent); + outline-offset: 3px; +} + +.image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + rgba(0, 0, 0, 0.05) 0%, + rgba(0, 0, 0, 0.35) 100% + ); + transition: background var(--transition-fast); +} + +.thumbnail:hover .overlay, +.thumbnail:focus-visible .overlay { + background: linear-gradient( + 135deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.45) 100% + ); +} + +.playIcon { + width: 46px; + height: 46px; + padding: 10px 10px 10px 12px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.92); + color: #111; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + transition: transform var(--transition-fast), + background-color var(--transition-fast); +} + +.thumbnail:hover .playIcon, +.thumbnail:focus-visible .playIcon { + transform: scale(1.08); + background-color: #fff; +} + +.caption { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: var(--font-size-xs); + line-height: 1.35; + font-weight: 500; +} + +@media (max-width: 768px) { + .thumbnail { + width: 180px; + } + + .playIcon { + width: 38px; + height: 38px; + padding: 8px 8px 8px 10px; + } +} diff --git a/website/app/components/VideoThumbnail.tsx b/website/app/components/VideoThumbnail.tsx new file mode 100644 index 0000000..2607dbf --- /dev/null +++ b/website/app/components/VideoThumbnail.tsx @@ -0,0 +1,50 @@ +import { getYouTubeId } from '@/lib/video-titles'; +import styles from './VideoThumbnail.module.css'; + +interface VideoThumbnailProps { + url: string; + title: string; + videoTitle?: string; +} + +export default function VideoThumbnail({ url, title, videoTitle }: VideoThumbnailProps) { + const videoId = getYouTubeId(url); + if (!videoId) return null; + + const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; + const ariaLabel = videoTitle + ? `Watch video: ${videoTitle}` + : `Watch video: ${title}`; + + return ( + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + + {videoTitle && {videoTitle}} + + ); +} diff --git a/website/app/pattern-catalog/CatalogView.tsx b/website/app/pattern-catalog/CatalogView.tsx index e836aea..e5f6b6a 100644 --- a/website/app/pattern-catalog/CatalogView.tsx +++ b/website/app/pattern-catalog/CatalogView.tsx @@ -10,6 +10,7 @@ import { CatalogGroupData, CatalogPreviewItem } from "./types"; import { COMPLETE_CATALOG_TEST_IDS } from "./test-ids"; import { getCategoryConfig } from "@/app/lib/category-config"; import SearchBar from "@/app/components/SearchBar"; +import VideoThumbnail from "@/app/components/VideoThumbnail"; import { PatternContent } from "@/lib/types"; interface CatalogViewProps { @@ -414,18 +415,39 @@ export default function CatalogView({ groups, title }: CatalogViewProps) { return (
-
- {selected.item.emojiIndicator && ( - - )} -

{selected.item.title}

+
+
+ {selected.item.emojiIndicator && ( + + )} +
+

{selected.item.title}

+ {selected.item.alternativeTitles && selected.item.alternativeTitles.length > 0 && ( +

+ Also known as: {selected.item.alternativeTitles.join(', ')} +

+ )} + {selected.item.synonyms && selected.item.synonyms.length > 0 && ( +

+ Synonyms: {selected.item.synonyms.join(', ')} +

+ )} +
+
+ + {selectedConfig.icon} + {selectedConfig.label} +
- - {selectedConfig.icon} - {selectedConfig.label} - + {selected.item.video && ( + + )}
diff --git a/website/app/pattern-catalog/page.module.css b/website/app/pattern-catalog/page.module.css index 9c30fb7..d837cd0 100644 --- a/website/app/pattern-catalog/page.module.css +++ b/website/app/pattern-catalog/page.module.css @@ -706,6 +706,13 @@ padding-right: var(--space-sm); } +.detailTitleBlock { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + .detailTitle { font-size: var(--font-size-3xl); color: var(--color-text-primary); @@ -713,6 +720,12 @@ font-weight: 600; } +.detailMeta { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + .detailBody { font-size: var(--font-size-base); color: var(--color-text-primary); @@ -892,13 +905,23 @@ .detailHeader { display: flex; - flex-direction: column; - gap: var(--space-md); + align-items: flex-start; + justify-content: space-between; + gap: var(--space-xl); padding-bottom: var(--space-md); border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-2xl); } +.detailHeaderMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); +} + .detailTitleRow { display: flex; align-items: flex-start; diff --git a/website/app/pattern-catalog/page.tsx b/website/app/pattern-catalog/page.tsx index 64d6dbb..44d25b1 100644 --- a/website/app/pattern-catalog/page.tsx +++ b/website/app/pattern-catalog/page.tsx @@ -83,11 +83,15 @@ function buildCatalogGroups(): CatalogGroupData[] { slug: pattern.slug, title: pattern.title, emojiIndicator: pattern.emojiIndicator, + alternativeTitles: pattern.alternativeTitles, + synonyms: pattern.synonyms, authorIds: pattern.authors ?? [], authorNames: resolveAuthorNames(pattern.authors), authorGithubs: pattern.authors?.map(resolveAuthorGithub).filter((g): g is string => g !== null) ?? [], summary: extractSummary(pattern.content), content: pattern.content, + video: pattern.video, + videoTitle: pattern.videoTitle, }) satisfies CatalogPreviewItem ); diff --git a/website/app/pattern-catalog/types.ts b/website/app/pattern-catalog/types.ts index 23f7107..eac03ea 100644 --- a/website/app/pattern-catalog/types.ts +++ b/website/app/pattern-catalog/types.ts @@ -4,11 +4,15 @@ export interface CatalogPreviewItem { slug: string; title: string; emojiIndicator?: string; + alternativeTitles?: string[]; + synonyms?: string[]; authorIds: string[]; authorNames: string[]; authorGithubs: string[]; summary?: string; content: string; + video?: string; + videoTitle?: string; } export interface CatalogGroupData { diff --git a/website/app/pattern-detail.module.css b/website/app/pattern-detail.module.css index 1398e97..301b646 100644 --- a/website/app/pattern-detail.module.css +++ b/website/app/pattern-detail.module.css @@ -24,16 +24,32 @@ } .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-xl); margin-bottom: var(--space-2xl); padding-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border); } +.headerMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); +} + +.headerAside { + flex-shrink: 0; +} + .titleWrapper { display: flex; align-items: flex-start; gap: var(--space-lg); - margin-bottom: var(--space-md); } .emoji { @@ -199,6 +215,15 @@ padding: var(--space-xl) var(--space-lg); } + .header { + flex-direction: column; + gap: var(--space-md); + } + + .headerAside { + align-self: flex-start; + } + .titleWrapper { flex-direction: column; gap: var(--space-md); diff --git a/website/lib/markdown.ts b/website/lib/markdown.ts index a1e94c2..0362bf7 100644 --- a/website/lib/markdown.ts +++ b/website/lib/markdown.ts @@ -3,6 +3,7 @@ import path from 'path' import matter from 'gray-matter' import { PatternCategory, PatternContent, RelationshipType } from './types' import { getRelationshipsForBoth } from './relationships' +import { getVideoTitle } from './video-titles' const PATTERNS_BASE_PATH = path.join(process.cwd(), '..', 'documents') @@ -180,6 +181,7 @@ export function getPatternBySlug( ...(data.alternative_titles && { alternativeTitles: data.alternative_titles }), ...(data.synonyms && { synonyms: data.synonyms }), ...(data.video && { video: data.video }), + ...(data.video && getVideoTitle(data.video) && { videoTitle: getVideoTitle(data.video) }), ...(mergedPatterns.length > 0 && { relatedPatterns: mergedPatterns }), ...(mergedAntiPatterns.length > 0 && { relatedAntiPatterns: mergedAntiPatterns }), ...(mergedObstacles.length > 0 && { relatedObstacles: mergedObstacles }), diff --git a/website/lib/types.ts b/website/lib/types.ts index 66df8bb..8280ac1 100644 --- a/website/lib/types.ts +++ b/website/lib/types.ts @@ -21,6 +21,7 @@ export interface PatternMetadata { alternativeTitles?: string[] synonyms?: string[] video?: string + videoTitle?: string relatedPatterns?: RelatedPattern[] relatedAntiPatterns?: RelatedPattern[] relatedObstacles?: RelatedPattern[] diff --git a/website/lib/video-titles.json b/website/lib/video-titles.json new file mode 100644 index 0000000..0436767 --- /dev/null +++ b/website/lib/video-titles.json @@ -0,0 +1,5 @@ +{ + "_LSK2bVf0Lc": "AI Talks - Lada Kesseler: Augmented Coding: Mapping the Uncharted Territory", + "GyI5qU9MNJU": "Ivett Ördög - Managing Cognitive Load in the Age of AI", + "9dyGJ2cg8p4": "How I Cut My Database Costs by 60% in 5 Days With AI" +} diff --git a/website/lib/video-titles.ts b/website/lib/video-titles.ts new file mode 100644 index 0000000..2b5b562 --- /dev/null +++ b/website/lib/video-titles.ts @@ -0,0 +1,24 @@ +import videoTitlesJson from './video-titles.json' + +const videoTitles: Record = videoTitlesJson + +export function getYouTubeId(url: string): string | null { + try { + const parsed = new URL(url) + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1) || null + } + if (parsed.hostname.endsWith('youtube.com')) { + return parsed.searchParams.get('v') + } + return null + } catch { + return null + } +} + +export function getVideoTitle(videoUrl: string): string | undefined { + const id = getYouTubeId(videoUrl) + if (!id) return undefined + return videoTitles[id] +} diff --git a/website/package.json b/website/package.json index edcbfe7..17df043 100644 --- a/website/package.json +++ b/website/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "validate": "node ../scripts/validate-relationships.js", + "fetch:videos": "node ../scripts/fetch-video-titles.mjs", "build": "npm run validate && next build", "start": "next start", "lint": "eslint", diff --git a/website/tests/unit/completeCatalog.test.tsx b/website/tests/unit/completeCatalog.test.tsx index cc129e5..6cff5d0 100644 --- a/website/tests/unit/completeCatalog.test.tsx +++ b/website/tests/unit/completeCatalog.test.tsx @@ -173,7 +173,9 @@ describe('PatternCatalogPage', () => { ).toBeInTheDocument() expect(within(detailPane).getByText(/Description/i)).toBeInTheDocument() expect(within(detailPane).getByText(/Documented by/i)).toBeInTheDocument() - expect(within(detailPane).getByText(/Lada Kesseler/i)).toBeInTheDocument() + expect( + within(detailPane).getByRole('link', { name: /Lada Kesseler's avatar Lada Kesseler/i }) + ).toBeInTheDocument() expect(within(detailPane).queryByText(/Open full entry/i)).not.toBeInTheDocument() }) diff --git a/website/tests/unit/components/pages.test.tsx b/website/tests/unit/components/pages.test.tsx index 9306744..9bbc4d9 100644 --- a/website/tests/unit/components/pages.test.tsx +++ b/website/tests/unit/components/pages.test.tsx @@ -472,7 +472,7 @@ describe('Pattern Detail Page', () => { }) describe('Video Display', () => { - it('displays a Watch video link when video is present', async () => { + it('displays a video thumbnail link when video is present', async () => { const videoUrl = 'https://www.youtube.com/watch?v=abc123&t=412' const patternWithVideo = { ...mockPattern, @@ -488,9 +488,16 @@ describe('Pattern Detail Page', () => { expect(link).toHaveAttribute('href', videoUrl) expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noopener noreferrer') + + const thumbnail = link.querySelector('img') + expect(thumbnail).toBeInTheDocument() + expect(thumbnail).toHaveAttribute( + 'src', + 'https://i.ytimg.com/vi/abc123/mqdefault.jpg' + ) }) - it('does not display Watch video link when video is absent', async () => { + it('does not display video thumbnail when video is absent', async () => { const params = Promise.resolve({ category: 'patterns', slug: 'test-pattern-detail' }) render(await PatternPage({ params }))