From 8a68bd0af7eb1b246357e77acaadf39574257528 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 11:19:54 +0200 Subject: [PATCH 1/5] feat(frontend): show included-feed starters on empty create Always select starter feeds in session, and surface them under an empty Create URL as lean Included feeds (Notice only when creation is disabled). --- frontend/src/__tests__/App.test.tsx | 107 ++++++++++++++++++++-- frontend/src/__tests__/catalog.test.ts | 9 ++ frontend/src/__tests__/useSession.test.ts | 26 ++++++ frontend/src/catalog/index.ts | 2 +- frontend/src/catalog/parseCatalog.ts | 12 ++- frontend/src/components/AppPanels.tsx | 70 +++++++++----- frontend/src/journey/copy.ts | 1 + frontend/src/session/useSession.ts | 8 +- 8 files changed, 199 insertions(+), 36 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 424b027e..92938d5c 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -361,17 +361,109 @@ describe('App', () => { expect(mockCreateFeed).not.toHaveBeenCalled(); }); - it('promotes included feeds when feed creation is disabled', async () => { + const azureStarter = { + id: 'microsoft.com/azure-products', + path: '/microsoft.com/azure-products.rss', + title: 'Azure product updates', + description: 'Follow Microsoft Azure product announcements from your own instance.', + channelUrl: 'https://azure.microsoft.com/updates', + parameterDefaults: {}, + }; + + it('shows lean included-feed starters on empty create when creation is enabled', async () => { + mockUseCatalogEntries.mockReturnValue([azureStarter]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( + 'href', + '/microsoft.com/azure-products.rss' + ); + }); + expect(screen.getByText(COPY.includedFeedsHint)).toBeInTheDocument(); + expect(screen.queryByText(COPY.includedFeedsIntro)).not.toBeInTheDocument(); + expect(screen.queryByText(COPY.includedFeedsLearnMore)).not.toBeInTheDocument(); + expect(document.querySelector('.notice')).toBeNull(); + expect(screen.getByRole('list', { name: COPY.includedFeedsTitle })).toBeInTheDocument(); + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByLabelText(COPY.urlLabel)); + }); + }); + + it('hides included-feed starters when the URL field is non-empty', async () => { + mockUseCatalogEntries.mockReturnValue([azureStarter]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Azure product updates' })).toBeInTheDocument(); + }); + + fireEvent.input(screen.getByLabelText(COPY.urlLabel), { + target: { value: 'example.com/articles' }, + }); + + expect(screen.queryByRole('link', { name: 'Azure product updates' })).not.toBeInTheDocument(); + expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); + }); + + it('hides included-feed starters when catalog find has hits', async () => { mockUseCatalogEntries.mockReturnValue([ + azureStarter, { - id: 'microsoft.com/azure-products', - path: '/microsoft.com/azure-products.rss', - title: 'Azure product updates', - description: 'Follow Microsoft Azure product announcements from your own instance.', - channelUrl: 'https://azure.microsoft.com/updates', + id: 'anthropic.com/news', + path: '/anthropic.com/news.rss', + title: 'Anthropic — News', + description: 'Product and research announcements from Anthropic.', + channelUrl: 'https://www.anthropic.com/news', parameterDefaults: {}, }, ]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Azure product updates' })).toBeInTheDocument(); + }); + + fireEvent.input(screen.getByLabelText(COPY.urlLabel), { + target: { value: 'www.anthropic.com/news' }, + }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Anthropic — News' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: 'Azure product updates' })).not.toBeInTheDocument(); + expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); + }); + + it('promotes included feeds when feed creation is disabled', async () => { + mockUseCatalogEntries.mockReturnValue([azureStarter]); mockUseApiMetadata.mockReturnValue({ metadata: { @@ -402,6 +494,9 @@ describe('App', () => { '/microsoft.com/azure-products.rss' ); expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument(); + expect(screen.getByText(COPY.includedFeedsIntro)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.includedFeedsLearnMore })).toBeInTheDocument(); + expect(document.querySelector('.notice')).not.toBeNull(); }); it('suggests included feeds when the URL matches the catalog', async () => { diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts index 3d08ad0c..bd9590be 100644 --- a/frontend/src/__tests__/catalog.test.ts +++ b/frontend/src/__tests__/catalog.test.ts @@ -141,4 +141,13 @@ describe('selectStarterFeeds', () => { ]); expect(selectStarterFeeds([other]).map((entry) => entry.id)).toEqual(['other.com/feed']); }); + + it('exports STARTER_FEED_IDS for lockstep assertions', async () => { + const { STARTER_FEED_IDS } = await import('../catalog'); + expect(STARTER_FEED_IDS).toEqual([ + 'microsoft.com/azure-products', + 'phys.org/weekly', + 'softwareleadweekly.com/issues', + ]); + }); }); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index 9511f158..a663535d 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -65,6 +65,32 @@ describe('useSession', () => { expect(result.current.feedCreationEnabled).toBe(true); }); + it('selects starter feeds when feed creation is enabled', async () => { + const azure = { + id: 'microsoft.com/azure-products', + path: '/microsoft.com/azure-products.rss', + channel: { url: 'https://azure.example' }, + directory: { title: 'Azure product updates', summary: 'Updates' }, + parameters: { defaults: {} }, + }; + mockFetchFor( + mockMetadata, + Response.json({ + success: true, + data: { configs: [azure] }, + meta: { total: 1, catalog_version: 1 }, + }) + ); + + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.feedCreationEnabled).toBe(true); + expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual([ + 'microsoft.com/azure-products', + ]); + }); + it('saves new tokens to persistent storage and does not write sessionStorage', async () => { mockFetchFor(mockMetadata); diff --git a/frontend/src/catalog/index.ts b/frontend/src/catalog/index.ts index 557d7ef4..44b96659 100644 --- a/frontend/src/catalog/index.ts +++ b/frontend/src/catalog/index.ts @@ -1,4 +1,4 @@ export type { CatalogEntry } from './types'; export { findCatalogEntries, catalogFeedHref } from './findCatalogEntries'; -export { parseCatalogEntries, selectStarterFeeds } from './parseCatalog'; +export { parseCatalogEntries, selectStarterFeeds, STARTER_FEED_IDS } from './parseCatalog'; export { useCatalogEntries } from './useCatalogEntries'; diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts index 9535aa4b..034114a2 100644 --- a/frontend/src/catalog/parseCatalog.ts +++ b/frontend/src/catalog/parseCatalog.ts @@ -61,10 +61,18 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { return entries; } -const STARTER_FEED_IDS = ['microsoft.com/azure-products', 'phys.org/weekly', 'softwareleadweekly.com/issues']; +/** + * Preferred included-feed starters (empty Create URL / creation-disabled Notice). + * Keep in lockstep with `Html2rss::Web::Catalog::Merge::STARTER_FEED_IDS`. + */ +export const STARTER_FEED_IDS = [ + 'microsoft.com/azure-products', + 'phys.org/weekly', + 'softwareleadweekly.com/issues', +] as const; /** - * Picks starter feeds for the creation-disabled surface. + * Picks up to three starter feeds by preferred id, else the first catalog rows. */ export function selectStarterFeeds(entries: readonly CatalogEntry[]): CatalogEntry[] { const selected = STARTER_FEED_IDS.map((id) => entries.find((entry) => entry.id === id)).filter( diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index b41888c8..79c4bebe 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -106,6 +106,45 @@ function CatalogHitList({ entries, ariaLabel, listboxId, activeIndex }: CatalogH ); } +function IncludedFeedsBlock({ + featuredFeeds, + lean, +}: { + featuredFeeds: readonly CatalogEntry[]; + lean: boolean; +}) { + if (lean) { + return ( +
+

{COPY.includedFeedsTitle}

+

{COPY.includedFeedsHint}

+ +
+ ); + } + + return ( + +

{COPY.includedFeedsIntro}

+ +

+ + {COPY.includedFeedsLearnMore} + +

+
+ ); +} + function UrlEntrySection({ url, disabled, @@ -120,6 +159,9 @@ function UrlEntrySection({ const catalogHits = useMemo(() => findCatalogEntries(url, catalogEntries), [url, catalogEntries]); const [activeHitIndex, setActiveHitIndex] = useState(undefined); const hasHits = catalogHits.length > 0; + const showStarters = + featuredFeeds.length > 0 && + (!feedCreationEnabled || (url.trim() === '' && !hasHits && !isCreating)); useEffect(() => { setActiveHitIndex(undefined); @@ -198,29 +240,11 @@ function UrlEntrySection({ )} {!feedCreationEnabled && ( - <> -

{COPY.creationDisabled}

- {featuredFeeds.length > 0 && ( - -

{COPY.includedFeedsIntro}

- -

- - {COPY.includedFeedsLearnMore} - -

-
- )} - +

{COPY.creationDisabled}

+ )} + + {showStarters && ( + )} ); diff --git a/frontend/src/journey/copy.ts b/frontend/src/journey/copy.ts index ee84f075..bbe7207e 100644 --- a/frontend/src/journey/copy.ts +++ b/frontend/src/journey/copy.ts @@ -21,6 +21,7 @@ export const COPY = { copied: 'Copied!', creationDisabled: 'Feed creation is disabled on this instance.', includedFeedsTitle: 'Included feeds', + includedFeedsHint: 'Open one to try this instance.', includedFeedsIntro: 'Start with a ready-made feed from this instance.', includedFeedsLearnMore: 'Learn how included feeds work.', catalogFindHint: 'Matching included feeds.', diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index 5d79feba..e4ca1949 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -29,10 +29,10 @@ export function useSession() { const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const feedCreationEnabled = feedCreation.enabled; const catalogEntries = useCatalogEntries(metadata); - const featuredFeeds: CatalogEntry[] = useMemo(() => { - if (feedCreationEnabled) return []; - return selectStarterFeeds(catalogEntries); - }, [catalogEntries, feedCreationEnabled]); + const featuredFeeds: CatalogEntry[] = useMemo( + () => selectStarterFeeds(catalogEntries), + [catalogEntries] + ); const mayCreate = (accessToken?: string): MayCreateResult => { if (!feedCreation.enabled) return 'disabled'; From 13d5f4b9f0cbc5b54e7939c0c37f22d9b6ad59b9 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 11:21:32 +0200 Subject: [PATCH 2/5] feat(catalog): prefer FAO FTC ICRC as starter feeds Swap FE and Ruby STARTER_FEED_IDS to Faraday-stable IGO/gov configs and document empty-URL Included feeds in Journey Grammar. --- AGENTS.md | 2 +- app/web/catalog/merge.rb | 8 +++-- docs/design-system.md | 2 +- frontend/src/__tests__/App.test.tsx | 36 +++++++++++------------ frontend/src/__tests__/catalog.test.ts | 14 ++++----- frontend/src/__tests__/useSession.test.ts | 16 +++++----- frontend/src/catalog/parseCatalog.ts | 6 ++-- spec/html2rss/web/catalog/merge_spec.rb | 19 ++++++++++++ 8 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 spec/html2rss/web/catalog/merge_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 07b31e81..4949b15d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Public feed-directory metadata for embedded and local configs. | Disabled response | `404` with `{ "error": "catalog_disabled" }` | | Embedded entries | `Html2rss::Configs::Catalog.entries` — do not re-walk YAML in the handler | | Local entries | `Catalog::Merge` includes `feeds.yml` feeds only when `directory.title` is set | -| Starter feeds (UI) | Frontend `selectStarterFeeds` when feed creation is disabled; catalog find uses full catalog when enabled | +| Starter feeds (UI) | Frontend `selectStarterFeeds` for empty Create / creation-disabled; catalog find uses full catalog when enabled | | Catalog find | `findCatalogEntries` → multi-hit list under create URL; links via `catalogFeedHref` (path + defaults) | | CORS | Route-scoped on `/api/v1/configs` only (`GET`, `OPTIONS`) | | Root metadata | `GET /api/v1/` exposes `instance.catalog: { enabled, url }` | diff --git a/app/web/catalog/merge.rb b/app/web/catalog/merge.rb index d7e6b773..77405782 100644 --- a/app/web/catalog/merge.rb +++ b/app/web/catalog/merge.rb @@ -8,10 +8,12 @@ module Web # Merges embedded catalog entries with local feed configs for the public catalog API. module Catalog module Merge + # Prefer Faraday-stable IGO/gov configs. Keep in lockstep with + # frontend `STARTER_FEED_IDS` in `frontend/src/catalog/parseCatalog.ts`. STARTER_FEED_IDS = %w[ - microsoft.com/azure-products - phys.org/weekly - softwareleadweekly.com/issues + fao.org/newsroom + ftc.gov/press-releases + icrc.org/news ].freeze module_function diff --git a/docs/design-system.md b/docs/design-system.md index f16deba3..3923288c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -38,7 +38,7 @@ If a page looks like it came from a different product, the change is wrong even ## Journey Grammar (enforced) -- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching included feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. +- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching included feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Included feeds starters may appear as subordinate chrome (lean field-help when creation is enabled; Notice when creation is disabled); hide them while typing, when find has hits, or while creating. - **Token gate:** a native `` over the still-mounted, inert URL task (one interactive task). Auth copy is in-field (`tokenError`); ActionFeedback stays on create. Access Token persists until Logout with no storage UI. - **Result:** primary CTA is **Copy feed URL**. Open feed / JSON / feed-reader are demoted secondary actions and stay available while preview loads. Preview is non-blocking confirmation only. - **Unmatched result:** `#/result/:token` is valid only with a matching in-memory result. Missing or mismatched tokens recover onto remounted `#/create` (no API rehydrate, no failure chrome, no durable shareable result page). diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 92938d5c..9cd60edb 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -361,17 +361,17 @@ describe('App', () => { expect(mockCreateFeed).not.toHaveBeenCalled(); }); - const azureStarter = { - id: 'microsoft.com/azure-products', - path: '/microsoft.com/azure-products.rss', - title: 'Azure product updates', - description: 'Follow Microsoft Azure product announcements from your own instance.', - channelUrl: 'https://azure.microsoft.com/updates', + const faoStarter = { + id: 'fao.org/newsroom', + path: '/fao.org/newsroom.rss', + title: 'FAO Newsroom', + description: 'News and media from the Food and Agriculture Organization.', + channelUrl: 'https://www.fao.org/newsroom', parameterDefaults: {}, }; it('shows lean included-feed starters on empty create when creation is enabled', async () => { - mockUseCatalogEntries.mockReturnValue([azureStarter]); + mockUseCatalogEntries.mockReturnValue([faoStarter]); mockUseAccessToken.mockReturnValue({ token: 'session-token', hasToken: true, @@ -384,9 +384,9 @@ describe('App', () => { render(); await waitFor(() => { - expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toHaveAttribute( 'href', - '/microsoft.com/azure-products.rss' + '/fao.org/newsroom.rss' ); }); expect(screen.getByText(COPY.includedFeedsHint)).toBeInTheDocument(); @@ -400,7 +400,7 @@ describe('App', () => { }); it('hides included-feed starters when the URL field is non-empty', async () => { - mockUseCatalogEntries.mockReturnValue([azureStarter]); + mockUseCatalogEntries.mockReturnValue([faoStarter]); mockUseAccessToken.mockReturnValue({ token: 'session-token', hasToken: true, @@ -413,20 +413,20 @@ describe('App', () => { render(); await waitFor(() => { - expect(screen.getByRole('link', { name: 'Azure product updates' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toBeInTheDocument(); }); fireEvent.input(screen.getByLabelText(COPY.urlLabel), { target: { value: 'example.com/articles' }, }); - expect(screen.queryByRole('link', { name: 'Azure product updates' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); }); it('hides included-feed starters when catalog find has hits', async () => { mockUseCatalogEntries.mockReturnValue([ - azureStarter, + faoStarter, { id: 'anthropic.com/news', path: '/anthropic.com/news.rss', @@ -448,7 +448,7 @@ describe('App', () => { render(); await waitFor(() => { - expect(screen.getByRole('link', { name: 'Azure product updates' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toBeInTheDocument(); }); fireEvent.input(screen.getByLabelText(COPY.urlLabel), { @@ -458,12 +458,12 @@ describe('App', () => { await waitFor(() => { expect(screen.getByRole('option', { name: 'Anthropic — News' })).toBeInTheDocument(); }); - expect(screen.queryByRole('link', { name: 'Azure product updates' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); }); it('promotes included feeds when feed creation is disabled', async () => { - mockUseCatalogEntries.mockReturnValue([azureStarter]); + mockUseCatalogEntries.mockReturnValue([faoStarter]); mockUseApiMetadata.mockReturnValue({ metadata: { @@ -489,9 +489,9 @@ describe('App', () => { await waitFor(() => { expect(screen.getByText(COPY.includedFeedsTitle)).toBeInTheDocument(); }); - expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toHaveAttribute( 'href', - '/microsoft.com/azure-products.rss' + '/fao.org/newsroom.rss' ); expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument(); expect(screen.getByText(COPY.includedFeedsIntro)).toBeInTheDocument(); diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts index bd9590be..32c4db2d 100644 --- a/frontend/src/__tests__/catalog.test.ts +++ b/frontend/src/__tests__/catalog.test.ts @@ -134,20 +134,18 @@ describe('parseCatalogEntries', () => { describe('selectStarterFeeds', () => { it('prefers known starter ids then falls back to the first three', () => { - const azure = baseEntry({ id: 'microsoft.com/azure-products', channelUrl: 'https://azure.example' }); + const fao = baseEntry({ id: 'fao.org/newsroom', channelUrl: 'https://fao.example' }); const other = baseEntry({ id: 'other.com/feed', channelUrl: 'https://other.example' }); - expect(selectStarterFeeds([other, azure]).map((entry) => entry.id)).toEqual([ - 'microsoft.com/azure-products', - ]); + expect(selectStarterFeeds([other, fao]).map((entry) => entry.id)).toEqual(['fao.org/newsroom']); expect(selectStarterFeeds([other]).map((entry) => entry.id)).toEqual(['other.com/feed']); }); it('exports STARTER_FEED_IDS for lockstep assertions', async () => { const { STARTER_FEED_IDS } = await import('../catalog'); - expect(STARTER_FEED_IDS).toEqual([ - 'microsoft.com/azure-products', - 'phys.org/weekly', - 'softwareleadweekly.com/issues', + expect([...STARTER_FEED_IDS]).toEqual([ + 'fao.org/newsroom', + 'ftc.gov/press-releases', + 'icrc.org/news', ]); }); }); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index a663535d..edc2b1b2 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -66,18 +66,18 @@ describe('useSession', () => { }); it('selects starter feeds when feed creation is enabled', async () => { - const azure = { - id: 'microsoft.com/azure-products', - path: '/microsoft.com/azure-products.rss', - channel: { url: 'https://azure.example' }, - directory: { title: 'Azure product updates', summary: 'Updates' }, + const fao = { + id: 'fao.org/newsroom', + path: '/fao.org/newsroom.rss', + channel: { url: 'https://www.fao.org/newsroom' }, + directory: { title: 'FAO Newsroom', summary: 'News' }, parameters: { defaults: {} }, }; mockFetchFor( mockMetadata, Response.json({ success: true, - data: { configs: [azure] }, + data: { configs: [fao] }, meta: { total: 1, catalog_version: 1 }, }) ); @@ -86,9 +86,7 @@ describe('useSession', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.feedCreationEnabled).toBe(true); - expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual([ - 'microsoft.com/azure-products', - ]); + expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual(['fao.org/newsroom']); }); it('saves new tokens to persistent storage and does not write sessionStorage', async () => { diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts index 034114a2..7377f49b 100644 --- a/frontend/src/catalog/parseCatalog.ts +++ b/frontend/src/catalog/parseCatalog.ts @@ -66,9 +66,9 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { * Keep in lockstep with `Html2rss::Web::Catalog::Merge::STARTER_FEED_IDS`. */ export const STARTER_FEED_IDS = [ - 'microsoft.com/azure-products', - 'phys.org/weekly', - 'softwareleadweekly.com/issues', + 'fao.org/newsroom', + 'ftc.gov/press-releases', + 'icrc.org/news', ] as const; /** diff --git a/spec/html2rss/web/catalog/merge_spec.rb b/spec/html2rss/web/catalog/merge_spec.rb new file mode 100644 index 00000000..afb196fc --- /dev/null +++ b/spec/html2rss/web/catalog/merge_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Catalog::Merge do + describe 'STARTER_FEED_IDS' do + it 'prefers Faraday-stable IGO and gov configs' do + expect(described_class::STARTER_FEED_IDS).to eq( + %w[ + fao.org/newsroom + ftc.gov/press-releases + icrc.org/news + ] + ) + end + end +end From 323df1d10ba25061cee1a8c30fc4986a746f93fa Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 11:26:40 +0200 Subject: [PATCH 3/5] fix(frontend): satisfy eslint boolean naming for starters Rename showStarters to shouldShowStarters and assert preferred starter ids resolve in the merged catalog. --- frontend/src/__tests__/catalog.test.ts | 6 +----- frontend/src/catalog/parseCatalog.ts | 6 +----- frontend/src/components/AppPanels.tsx | 13 ++++--------- frontend/src/session/useSession.ts | 5 +---- spec/html2rss/web/catalog/merge_spec.rb | 5 +++++ 5 files changed, 12 insertions(+), 23 deletions(-) diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts index 32c4db2d..67f88a59 100644 --- a/frontend/src/__tests__/catalog.test.ts +++ b/frontend/src/__tests__/catalog.test.ts @@ -142,10 +142,6 @@ describe('selectStarterFeeds', () => { it('exports STARTER_FEED_IDS for lockstep assertions', async () => { const { STARTER_FEED_IDS } = await import('../catalog'); - expect([...STARTER_FEED_IDS]).toEqual([ - 'fao.org/newsroom', - 'ftc.gov/press-releases', - 'icrc.org/news', - ]); + expect([...STARTER_FEED_IDS]).toEqual(['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news']); }); }); diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts index 7377f49b..d43423cf 100644 --- a/frontend/src/catalog/parseCatalog.ts +++ b/frontend/src/catalog/parseCatalog.ts @@ -65,11 +65,7 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { * Preferred included-feed starters (empty Create URL / creation-disabled Notice). * Keep in lockstep with `Html2rss::Web::Catalog::Merge::STARTER_FEED_IDS`. */ -export const STARTER_FEED_IDS = [ - 'fao.org/newsroom', - 'ftc.gov/press-releases', - 'icrc.org/news', -] as const; +export const STARTER_FEED_IDS = ['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news'] as const; /** * Picks up to three starter feeds by preferred id, else the first catalog rows. diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 79c4bebe..35dffe09 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -159,9 +159,8 @@ function UrlEntrySection({ const catalogHits = useMemo(() => findCatalogEntries(url, catalogEntries), [url, catalogEntries]); const [activeHitIndex, setActiveHitIndex] = useState(undefined); const hasHits = catalogHits.length > 0; - const showStarters = - featuredFeeds.length > 0 && - (!feedCreationEnabled || (url.trim() === '' && !hasHits && !isCreating)); + const shouldShowStarters = + featuredFeeds.length > 0 && (!feedCreationEnabled || (url.trim() === '' && !hasHits && !isCreating)); useEffect(() => { setActiveHitIndex(undefined); @@ -239,13 +238,9 @@ function UrlEntrySection({ )} - {!feedCreationEnabled && ( -

{COPY.creationDisabled}

- )} + {!feedCreationEnabled &&

{COPY.creationDisabled}

} - {showStarters && ( - - )} + {shouldShowStarters && } ); } diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index e4ca1949..d2e5b078 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -29,10 +29,7 @@ export function useSession() { const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const feedCreationEnabled = feedCreation.enabled; const catalogEntries = useCatalogEntries(metadata); - const featuredFeeds: CatalogEntry[] = useMemo( - () => selectStarterFeeds(catalogEntries), - [catalogEntries] - ); + const featuredFeeds: CatalogEntry[] = useMemo(() => selectStarterFeeds(catalogEntries), [catalogEntries]); const mayCreate = (accessToken?: string): MayCreateResult => { if (!feedCreation.enabled) return 'disabled'; diff --git a/spec/html2rss/web/catalog/merge_spec.rb b/spec/html2rss/web/catalog/merge_spec.rb index afb196fc..f46d7a62 100644 --- a/spec/html2rss/web/catalog/merge_spec.rb +++ b/spec/html2rss/web/catalog/merge_spec.rb @@ -15,5 +15,10 @@ ] ) end + + it 'resolves each preferred id in the merged catalog' do + catalog_ids = described_class.call.map { |entry| entry.fetch(:id) } + expect(catalog_ids).to include(*described_class::STARTER_FEED_IDS) + end end end From dbd94220f406d3d549749faaf55cc9a75af88798 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 11:45:04 +0200 Subject: [PATCH 4/5] fix(frontend): align Feed Directory chrome and escape Use the docs SSOT product name for starters, find chrome, and utility links; drop Try included feeds drift and lean hint noise. --- docs/design-system.md | 4 +-- frontend/src/__tests__/App.test.tsx | 41 +++++++++++++++++------- frontend/src/components/AppPanels.tsx | 45 +++++++++++++++------------ frontend/src/journey/copy.ts | 13 ++++---- 4 files changed, 62 insertions(+), 41 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 3923288c..3e851225 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -38,7 +38,7 @@ If a page looks like it came from a different product, the change is wrong even ## Journey Grammar (enforced) -- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching included feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Included feeds starters may appear as subordinate chrome (lean field-help when creation is enabled; Notice when creation is disabled); hide them while typing, when find has hits, or while creating. +- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Feed Directory starters may appear as subordinate chrome (lean: `.ui-eyebrow` + list + demoted Feed Directory escape under the list when creation is enabled; Notice when creation is disabled); hide starters and the under-list escape while typing, when find has hits, or while creating. - **Token gate:** a native `` over the still-mounted, inert URL task (one interactive task). Auth copy is in-field (`tokenError`); ActionFeedback stays on create. Access Token persists until Logout with no storage UI. - **Result:** primary CTA is **Copy feed URL**. Open feed / JSON / feed-reader are demoted secondary actions and stay available while preview loads. Preview is non-blocking confirmation only. - **Unmatched result:** `#/result/:token` is valid only with a matching in-memory result. Missing or mismatched tokens recover onto remounted `#/create` (no API rehydrate, no failure chrome, no durable shareable result page). @@ -55,7 +55,7 @@ If a page looks like it came from a different product, the change is wrong even | Result | **Feed ready** / **Copy feed URL** | | Retry | **Try again** (button only) | | Preview | **Checking preview** / **Check again** | -| Catalog | **Included feeds** | +| Catalog | **Feed Directory** | | Token | **Access token** | ## Non-Negotiable Surface Rules diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 9cd60edb..04c44df4 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -389,11 +389,20 @@ describe('App', () => { '/fao.org/newsroom.rss' ); }); - expect(screen.getByText(COPY.includedFeedsHint)).toBeInTheDocument(); - expect(screen.queryByText(COPY.includedFeedsIntro)).not.toBeInTheDocument(); - expect(screen.queryByText(COPY.includedFeedsLearnMore)).not.toBeInTheDocument(); + const starters = screen.getByRole('status'); + expect(starters.querySelector('.ui-eyebrow')?.textContent).toBe(COPY.feedDirectory); + expect(screen.queryByText(COPY.feedDirectoryIntro)).not.toBeInTheDocument(); + expect(screen.queryByText(COPY.feedDirectoryLearnMore)).not.toBeInTheDocument(); expect(document.querySelector('.notice')).toBeNull(); - expect(screen.getByRole('list', { name: COPY.includedFeedsTitle })).toBeInTheDocument(); + expect(screen.getByRole('list', { name: COPY.feedDirectory })).toBeInTheDocument(); + const directoryLinks = screen.getAllByRole('link', { name: COPY.feedDirectory }); + expect(directoryLinks).toHaveLength(2); + for (const link of directoryLinks) { + expect(link).toHaveAttribute( + 'href', + 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' + ); + } await waitFor(() => { expect(document.activeElement).toBe(screen.getByLabelText(COPY.urlLabel)); }); @@ -421,7 +430,11 @@ describe('App', () => { }); expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); - expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.feedDirectory })).toHaveAttribute( + 'href', + 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' + ); }); it('hides included-feed starters when catalog find has hits', async () => { @@ -459,7 +472,8 @@ describe('App', () => { expect(screen.getByRole('option', { name: 'Anthropic — News' })).toBeInTheDocument(); }); expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); - expect(screen.queryByText(COPY.includedFeedsHint)).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.feedDirectory })).toBeInTheDocument(); }); it('promotes included feeds when feed creation is disabled', async () => { @@ -487,15 +501,18 @@ describe('App', () => { render(); await waitFor(() => { - expect(screen.getByText(COPY.includedFeedsTitle)).toBeInTheDocument(); + expect(document.querySelector('.notice__title')?.textContent).toBe(COPY.feedDirectory); }); expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toHaveAttribute( 'href', '/fao.org/newsroom.rss' ); expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument(); - expect(screen.getByText(COPY.includedFeedsIntro)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: COPY.includedFeedsLearnMore })).toBeInTheDocument(); + expect(screen.getByText(COPY.feedDirectoryIntro)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.feedDirectoryLearnMore })).toHaveAttribute( + 'href', + 'https://html2rss.github.io/web-application/guides/use-the-feed-directory/' + ); expect(document.querySelector('.notice')).not.toBeNull(); }); @@ -858,7 +875,7 @@ describe('App', () => { ].map((element) => element.textContent); expect(utilityItems).toEqual([ - COPY.tryIncludedFeeds, + COPY.feedDirectory, COPY.bookmarkletTitle, COPY.logout, COPY.dockerInstall, @@ -1243,7 +1260,7 @@ describe('App', () => { ...screen.getByLabelText(COPY.utilities).querySelectorAll(':scope .utility-strip__items > a'), ].map((link) => link.textContent); expect(utilityLinks).toEqual([ - COPY.tryIncludedFeeds, + COPY.feedDirectory, COPY.bookmarkletTitle, COPY.dockerInstall, COPY.openapiSpec, @@ -1254,7 +1271,7 @@ describe('App', () => { 'href', 'https://example.test/openapi.yaml' ); - expect(screen.getByRole('link', { name: COPY.tryIncludedFeeds })).toHaveAttribute( + expect(screen.getByRole('link', { name: COPY.feedDirectory })).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 35dffe09..c5f1412c 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -106,6 +106,15 @@ function CatalogHitList({ entries, ariaLabel, listboxId, activeIndex }: CatalogH ); } +function feedDirectoryHref(): string { + const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); + if (globalThis.window === undefined) return directoryUrl.href; + + const instanceUrl = new URL('/', location.origin); + directoryUrl.hash = `!url=${encodeURIComponent(instanceUrl.href)}`; + return directoryUrl.href; +} + function IncludedFeedsBlock({ featuredFeeds, lean, @@ -115,10 +124,14 @@ function IncludedFeedsBlock({ }) { if (lean) { return ( -
-

{COPY.includedFeedsTitle}

-

{COPY.includedFeedsHint}

- +
+

{COPY.feedDirectory}

+ +

+ + {COPY.feedDirectory} + +

); } @@ -127,18 +140,18 @@ function IncludedFeedsBlock({ -

{COPY.includedFeedsIntro}

- +

{COPY.feedDirectoryIntro}

+

- {COPY.includedFeedsLearnMore} + {COPY.feedDirectoryLearnMore}

@@ -513,20 +526,12 @@ export function UtilityStrip({ onShowBookmarkletHelp, }: UtilityStripProperties) { const normalizedOpenapiUrl = normalizeLocalOriginUrl(openapiUrl); - const includedFeedsHref = (() => { - const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); - if (globalThis.window === undefined) return directoryUrl.href; - - const instanceUrl = new URL('/', location.origin); - directoryUrl.hash = `!url=${encodeURIComponent(instanceUrl.href)}`; - return directoryUrl.href; - })(); return (
- - {COPY.tryIncludedFeeds} + + {COPY.feedDirectory} {hasAccessToken && ( diff --git a/frontend/src/journey/copy.ts b/frontend/src/journey/copy.ts index bbe7207e..060b594a 100644 --- a/frontend/src/journey/copy.ts +++ b/frontend/src/journey/copy.ts @@ -20,12 +20,12 @@ export const COPY = { copy: 'Copy', copied: 'Copied!', creationDisabled: 'Feed creation is disabled on this instance.', - includedFeedsTitle: 'Included feeds', - includedFeedsHint: 'Open one to try this instance.', - includedFeedsIntro: 'Start with a ready-made feed from this instance.', - includedFeedsLearnMore: 'Learn how included feeds work.', - catalogFindHint: 'Matching included feeds.', - catalogFindHitsLabel: 'Matching included feeds', + /** Browse product name — SSOT with docs /feed-directory/ title. */ + feedDirectory: 'Feed Directory', + feedDirectoryIntro: 'Start with a ready-made feed from this instance.', + feedDirectoryLearnMore: 'Learn how the Feed Directory works.', + catalogFindHint: 'Matching feeds.', + catalogFindHitsLabel: 'Matching feeds', dockerSetup: 'Set up your own instance with Docker.', dockerInstall: 'Install from Docker Hub', bookmarkletTitle: 'Bookmarklet', @@ -41,7 +41,6 @@ export const COPY = { saveAndContinue: 'Save and continue', back: 'Back', utilities: 'Utilities', - tryIncludedFeeds: 'Try included feeds', logout: 'Logout', openapiSpec: 'OpenAPI spec', sourceCode: 'Source code', From a8d4cc7e2138e47d9a2058e24f332c372b43c2ef Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 11:51:01 +0200 Subject: [PATCH 5/5] fix(frontend): demote Feed Directory escape with browse CTA Reuse utility-link for the lean under-list escape, put muted color on notice meta anchors, and share Browse Feed Directory (N) with the utility strip using catalogEntries.length. --- docs/design-system.md | 2 +- frontend/src/__tests__/App.test.tsx | 13 ++++++------ frontend/src/components/App.tsx | 1 + frontend/src/components/AppPanels.tsx | 30 +++++++++++++++++++-------- frontend/src/journey/copy.ts | 3 +++ frontend/src/styles/main.css | 9 ++++++++ 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 3e851225..ca3a8723 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -38,7 +38,7 @@ If a page looks like it came from a different product, the change is wrong even ## Journey Grammar (enforced) -- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Feed Directory starters may appear as subordinate chrome (lean: `.ui-eyebrow` + list + demoted Feed Directory escape under the list when creation is enabled; Notice when creation is disabled); hide starters and the under-list escape while typing, when find has hits, or while creating. +- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Feed Directory starters may appear as subordinate chrome (lean: `.ui-eyebrow` + list + demoted `utility-link` escape under the list when creation is enabled; Notice when creation is disabled). Escape and utility-strip share **Browse Feed Directory (N)** (`catalogEntries.length`); hide starters and the under-list escape while typing, when find has hits, or while creating. - **Token gate:** a native `` over the still-mounted, inert URL task (one interactive task). Auth copy is in-field (`tokenError`); ActionFeedback stays on create. Access Token persists until Logout with no storage UI. - **Result:** primary CTA is **Copy feed URL**. Open feed / JSON / feed-reader are demoted secondary actions and stay available while preview loads. Preview is non-blocking confirmation only. - **Unmatched result:** `#/result/:token` is valid only with a matching in-memory result. Missing or mismatched tokens recover onto remounted `#/create` (no API rehydrate, no failure chrome, no durable shareable result page). diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 04c44df4..db63d4a6 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -395,13 +395,14 @@ describe('App', () => { expect(screen.queryByText(COPY.feedDirectoryLearnMore)).not.toBeInTheDocument(); expect(document.querySelector('.notice')).toBeNull(); expect(screen.getByRole('list', { name: COPY.feedDirectory })).toBeInTheDocument(); - const directoryLinks = screen.getAllByRole('link', { name: COPY.feedDirectory }); + const directoryLinks = screen.getAllByRole('link', { name: COPY.browseFeedDirectory(1) }); expect(directoryLinks).toHaveLength(2); for (const link of directoryLinks) { expect(link).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); + expect(link).toHaveClass('utility-link'); } await waitFor(() => { expect(document.activeElement).toBe(screen.getByLabelText(COPY.urlLabel)); @@ -431,7 +432,7 @@ describe('App', () => { expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: COPY.feedDirectory })).toHaveAttribute( + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory(1) })).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); @@ -473,7 +474,7 @@ describe('App', () => { }); expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: COPY.feedDirectory })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory(2) })).toBeInTheDocument(); }); it('promotes included feeds when feed creation is disabled', async () => { @@ -875,7 +876,7 @@ describe('App', () => { ].map((element) => element.textContent); expect(utilityItems).toEqual([ - COPY.feedDirectory, + COPY.browseFeedDirectory(), COPY.bookmarkletTitle, COPY.logout, COPY.dockerInstall, @@ -1260,7 +1261,7 @@ describe('App', () => { ...screen.getByLabelText(COPY.utilities).querySelectorAll(':scope .utility-strip__items > a'), ].map((link) => link.textContent); expect(utilityLinks).toEqual([ - COPY.feedDirectory, + COPY.browseFeedDirectory(), COPY.bookmarkletTitle, COPY.dockerInstall, COPY.openapiSpec, @@ -1271,7 +1272,7 @@ describe('App', () => { 'href', 'https://example.test/openapi.yaml' ); - expect(screen.getByRole('link', { name: COPY.feedDirectory })).toHaveAttribute( + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory() })).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index f7f338bb..daebf778 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -175,6 +175,7 @@ export function App() {