diff --git a/client/package.json b/client/package.json index 830c086..11820db 100644 --- a/client/package.json +++ b/client/package.json @@ -12,8 +12,11 @@ "test:watch": "vitest" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "rxjs": "^7.8.2" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/client/src/App.css b/client/src/App.css index 9348f52..e52c0ed 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,84 +1,89 @@ -/* Plain CSS, no framework — smaller bundle, and full control over focus - styles and contrast for tilgjengelighet. */ - -:root { - color-scheme: light dark; - --bg: #fafaf8; - --surface: #ffffff; - --text: #1a1a1a; - --muted: #5c5c5c; - --accent: #2f5d50; - --focus: #1a73e8; - font-family: - system-ui, - -apple-system, - 'Segoe UI', - Roboto, - sans-serif; - line-height: 1.5; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #141414; - --surface: #1e1e1e; - --text: #f2f2f0; - --muted: #a8a8a4; - --accent: #6fae9c; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); -} - .skip-link { position: absolute; left: -9999px; - top: 0; - background: var(--accent); - color: var(--surface); - padding: 0.5rem 1rem; + top: 0.5rem; + z-index: 5; + background: var(--surface); + color: var(--accent); + padding: 0.6rem 1rem; } .skip-link:focus { - left: 0; + left: 0.5rem; } - -:where(a, button, input, select, textarea, [tabindex]):focus-visible { - outline: 3px solid var(--focus); - outline-offset: 2px; +.site-header { + background: #181e1b; + color: #f7f5ef; } - -header { - padding: 1.5rem; - border-bottom: 1px solid color-mix(in srgb, var(--text) 15%, transparent); +.navigation { + max-width: 1240px; + margin: auto; + padding: 1rem 2rem; + display: flex; + align-items: center; + gap: 2.5rem; +} +.navigation a { + text-decoration: none; +} +.navigation a[aria-current] { + border-bottom: 2px solid #a9c3a3; + padding-block: 0.4rem; +} +.brand { + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.08rem; } -header h1 { +.brand span { + color: #a9c3a3; + font-size: 0.65rem; + margin-left: 0.25rem; +} +#main { + max-width: 1240px; + padding: 0 2rem; + margin: auto; +} +.search-section { + text-align: center; + padding: 2.5rem 0 2rem; +} +.search-section h1 { margin: 0; + font-size: clamp(2rem, 4.5vw, 3.5rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.15; } .tagline { - margin: 0.25rem 0 0; color: var(--muted); + margin: 1rem 0 1.7rem; } - -main { +.catalogue-layout { + border-top: 1px solid var(--border); + padding-top: 1.25rem; +} +.site-footer { + text-align: center; + border-top: 1px solid var(--border); padding: 1.5rem; - max-width: 60rem; - margin: 0 auto; + font-size: 0.8rem; + color: var(--muted); +} +@media (max-width: 700px) { + #main { + padding-inline: 1rem; + } + .navigation { + padding: 1rem; + gap: 1.5rem; + } + .search-section { + padding-block: 2rem; + } +} +@media (max-width: 420px) { + .navigation { + gap: 1rem; + } } diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 560710a..1ba7ae4 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -1,22 +1,246 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing/react'; +import type { MockedResponse } from '@apollo/client/testing'; +import { GraphQLError } from 'graphql'; import App from './App.js'; +import { SEARCH_ALBUMS } from './api/searchAlbums.js'; +import type { + AlbumSummary, + SearchAlbumsData, + SearchAlbumsVariables, +} from './api/searchAlbums.js'; -describe('App', () => { - it('renders the product name and tagline', () => { - render(); - expect(screen.getByRole('heading', { level: 1, name: 'Crate' })).toBeInTheDocument(); - expect(screen.getByText('Search, review and collect albums.')).toBeInTheDocument(); +const album: AlbumSummary = { + id: '1', + title: 'Abbey Road', + artists: [{ id: '1', name: 'The Beatles' }], + releaseYear: 1969, + genres: [{ id: '1', name: 'Rock' }], + averageRating: 4.8, + reviewCount: 1240, +}; + +function mockSearch( + query: string, + albums: AlbumSummary[] = [album], +): MockedResponse { + return { + request: { + query: SEARCH_ALBUMS, + variables: { input: { query, sort: 'RELEVANCE' }, first: 20, after: null }, + }, + result: { + data: { + searchAlbums: { + edges: albums.map((node) => ({ cursor: node.id, node })), + totalCount: albums.length, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + delay: 10, + }; +} + +function renderApp( + mocks: MockedResponse[] = [], +) { + return render( + + + , + ); +} + +function submitSearch(query: string) { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: query } }); + fireEvent.submit(screen.getByRole('search')); +} + +describe('App search', () => { + it.each(['Enter', 'Search button'])( + 'retries a failed query through %s', + async (method) => { + const failed = { + ...mockSearch('Abbey'), + result: { errors: [new GraphQLError('Search failed')] }, + }; + renderApp([failed, mockSearch('Abbey')]); + submitSearch('Abbey'); + await screen.findByRole('alert'); + + if (method === 'Enter') { + const input = screen.getByRole('searchbox'); + input.focus(); + const defaultAllowed = fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + // jsdom needs the browser's implicit submission to be simulated explicitly. + if (defaultAllowed) act(() => input.form?.requestSubmit()); + fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' }); + expect(input).toHaveFocus(); + } else { + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + } + + expect( + await screen.findByRole('article', { name: 'Abbey Road' }), + ).toBeInTheDocument(); + }, + ); + + it('does not request the same successful query again', async () => { + const result = vi.fn(() => ({ + data: { + searchAlbums: { + edges: [], + totalCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + })); + renderApp([{ ...mockSearch('Abbey'), result, maxUsageCount: 3 }]); + submitSearch('Abbey'); + await screen.findByRole('heading', { name: 'No albums found' }); + submitSearch('Abbey'); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + await act(() => new Promise((resolve) => setTimeout(resolve, 350))); + expect(result).toHaveBeenCalledTimes(1); + }); + + it('keeps the retry panel and focus stable without duplicate requests during loading', async () => { + const failed = { + ...mockSearch('Abbey'), + result: undefined, + error: new Error('Network unavailable'), + }; + const result = vi.fn(() => ({ + data: { + searchAlbums: { + edges: [{ cursor: '1', node: album }], + totalCount: 1, + pageInfo: { hasNextPage: false, endCursor: '1' }, + }, + }, + })); + renderApp([failed, { ...mockSearch('Abbey'), result, delay: 100, maxUsageCount: 3 }]); + submitSearch('Abbey'); + await screen.findByRole('alert'); + const retry = screen.getByRole('button', { name: 'Retry search' }); + const panel = retry.parentElement; + retry.focus(); + fireEvent.click(retry); + + expect(screen.getByRole('button', { name: 'Retry search' })).toBe(retry); + expect(retry.parentElement).toBe(panel); + expect(retry).toHaveFocus(); + await waitFor(() => expect(retry).toHaveAttribute('aria-disabled', 'true')); + expect(screen.getByRole('status')).toHaveTextContent('Retrying search'); + expect(screen.getByRole('region', { name: 'Search results' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + fireEvent.click(retry); + submitSearch('Abbey'); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + + await screen.findByRole('article', { name: 'Abbey Road' }); + await act(() => new Promise((resolve) => setTimeout(resolve, 150))); + expect(result).toHaveBeenCalledTimes(1); }); - it('exposes a skip link as the first focusable element', () => { - render(); - const skip = screen.getByRole('link', { name: 'Skip to content' }); - expect(skip).toHaveAttribute('href', '#main'); + it('starts with labelled search and accessible landmarks, without unrelated controls', () => { + renderApp(); + expect( + screen.getByRole('searchbox', { name: 'Search by album or artist' }), + ).toHaveValue(''); + expect(screen.getByRole('main')).toHaveAttribute('id', 'main'); + expect(document.querySelector('a')).toBe( + screen.getByRole('link', { name: 'Skip to content' }), + ); + expect(screen.getByRole('status')).toHaveTextContent('Search for an album or artist'); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); + + it('shows loading and then renders GraphQL result metadata after submission', async () => { + renderApp([mockSearch('Abbey')]); + submitSearch(' Abbey '); + expect(screen.getByRole('status')).toHaveTextContent('Searching albums'); + expect(screen.getAllByText('Searching albums…')).toHaveLength(1); + expect( + screen.queryByRole('button', { name: 'Retry search' }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('No albums found')).not.toBeInTheDocument(); + + const card = await screen.findByRole('article', { name: 'Abbey Road' }); + expect(within(card).getByText('The Beatles')).toBeInTheDocument(); + expect(within(card).getByText('1969')).toBeInTheDocument(); + expect(within(card).getByText('Rock')).toBeInTheDocument(); + expect(within(card).getByText('1,240 reviews')).toBeInTheDocument(); + expect(card).toHaveTextContent('Average rating: 4.8 out of 5'); + expect(screen.getByRole('status')).toHaveTextContent('Showing 1 of 1 albums.'); + }); + + it('shows an empty result and clears the query without querying fixtures', async () => { + renderApp([mockSearch('missing', [])]); + submitSearch('missing'); + expect( + await screen.findByRole('heading', { name: 'No albums found' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })); + expect(screen.getByRole('searchbox')).toHaveValue(''); + expect(screen.getByRole('status')).toHaveTextContent('Search for an album or artist'); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + }); + + it('shows network errors and retries the same search', async () => { + const failed = { + ...mockSearch('Abbey'), + result: undefined, + error: new Error('Network unavailable'), + }; + renderApp([failed, mockSearch('Abbey')]); + submitSearch('Abbey'); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load albums'); + expect(screen.queryByText('No albums found')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry search' })); + expect( + await screen.findByRole('article', { name: 'Abbey Road' }), + ).toBeInTheDocument(); + }); + + it('shows GraphQL response errors and retries successfully', async () => { + const failed = { + ...mockSearch('Abbey'), + // A successful HTTP response can contain GraphQL errors instead of data. + result: { errors: [new GraphQLError('Could not resolve searchAlbums')] }, + }; + renderApp([failed, mockSearch('Abbey')]); + submitSearch('Abbey'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load albums'); + expect(screen.getByRole('status')).toHaveTextContent('Search failed.'); + expect(screen.queryByText('No albums found')).not.toBeInTheDocument(); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Retry search' })); + + expect( + await screen.findByRole('article', { name: 'Abbey Road' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Showing 1 of 1 albums.'); }); - it('renders a main landmark', () => { - render(); - expect(screen.getByRole('main')).toBeInTheDocument(); + it('replaces results for a new query rather than retaining the previous search', async () => { + renderApp([mockSearch('Abbey'), mockSearch('missing', [])]); + submitSearch('Abbey'); + await screen.findByRole('article', { name: 'Abbey Road' }); + submitSearch('missing'); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'No albums found' }), + ).toBeInTheDocument(); }); }); diff --git a/client/src/App.tsx b/client/src/App.tsx index fc20951..0f55240 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,21 +1,48 @@ -/** - * M1 placeholder. The point of this file is to prove the tooling works: - * typecheck, lint, tests and build are all green before #9 replaces it with - * the mock-data search interface. - */ +import './App.css'; + +import SearchForm from './components/SearchForm.js'; +import ResultsLayout from './components/ResultsLayout.js'; +import { useAlbumSearch } from './hooks/useAlbumSearch.js'; + export default function App() { + const search = useAlbumSearch(); + return ( <> Skip to content -
-

Crate

-

Search, review and collect albums.

+
+
-

Skeleton only — the mock-data search interface arrives in issue #9.

+
+

A world of records

+

Discover your next album.

+

Find something worth listening to.

+ +
+
+ search.search('')} + /> +
+
Crate · Find something worth listening to.
); } diff --git a/client/src/api/client.test.ts b/client/src/api/client.test.ts new file mode 100644 index 0000000..93412c4 --- /dev/null +++ b/client/src/api/client.test.ts @@ -0,0 +1,106 @@ +import schemaSource from '../../../schema.graphql?raw'; +import { buildSchema, validate } from 'graphql'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { cache } from './client.js'; +import { SEARCH_ALBUMS } from './searchAlbums.js'; +import type { + AlbumSearchInput, + SearchAlbumsData, + SearchAlbumsVariables, +} from './searchAlbums.js'; + +const input: AlbumSearchInput = { query: 'record', sort: 'RELEVANCE' }; + +const variables: SearchAlbumsVariables = { input, first: 2, after: null }; + +function result(ids: string[], endCursor: string, hasNextPage = true): SearchAlbumsData { + return { + searchAlbums: { + edges: ids.map((id) => ({ + cursor: `cursor-${id}`, + node: { + __typename: 'Album' as const, + id, + title: `Album ${id}`, + artists: [], + genres: [], + releaseYear: null, + averageRating: null, + reviewCount: 0, + }, + })), + pageInfo: { endCursor, hasNextPage }, + totalCount: 4, + }, + }; +} + +function write(data: SearchAlbumsData, overrides: Partial = {}) { + cache.writeQuery({ + query: SEARCH_ALBUMS, + variables: { ...variables, ...overrides }, + data, + }); +} + +function read(overrides: Partial = {}) { + return cache.readQuery({ + query: SEARCH_ALBUMS, + variables: { ...variables, ...overrides }, + }); +} + +beforeEach(() => cache.restore({})); + +it('validates the search operation against the repository schema', () => { + const schema = buildSchema(schemaSource); + + expect(validate(schema, SEARCH_ALBUMS)).toEqual([]); +}); + +describe('searchAlbums cache policy', () => { + it('appends cursor pages, deduplicates normalized albums and updates metadata', () => { + write(result(['1', '2'], 'cursor-2')); + write(result(['2', '3', '4'], 'cursor-4', false), { after: 'cursor-2' }); + + const connection = read()?.searchAlbums; + + expect(connection?.edges.map((edge) => edge.node.id)).toEqual(['1', '2', '3', '4']); + expect(connection?.pageInfo).toEqual({ endCursor: 'cursor-4', hasNextPage: false }); + expect(connection?.totalCount).toBe(4); + expect(cache.extract()['Album:1']).toBeDefined(); + expect(read({ first: 20, after: 'cursor-2' })).toEqual(read()); + }); + + it.each([ + { ...input, query: 'different' }, + { ...input, genreIds: ['rock'] }, + { ...input, decades: [1990] }, + { ...input, sort: 'TITLE_ASC' as const }, + ])('keeps a different search input isolated: %j', (otherInput) => { + write(result(['1'], 'cursor-1')); + write(result(['2'], 'cursor-2'), { input: otherInput }); + + expect(read()?.searchAlbums.edges.map((edge) => edge.node.id)).toEqual(['1']); + expect( + read({ input: otherInput })?.searchAlbums.edges.map((edge) => edge.node.id), + ).toEqual(['2']); + }); + + it('replaces accumulated pages when the first page is refreshed', () => { + write(result(['1', '2'], 'cursor-2')); + write(result(['3'], 'cursor-3'), { after: 'cursor-2' }); + write(result(['4'], 'cursor-4', false)); + expect(read()?.searchAlbums.edges.map((edge) => edge.node.id)).toEqual(['4']); + }); + + it('ignores repeated pages and continuations whose cursor no longer matches the tail', () => { + write(result(['1'], 'cursor-1')); + write(result(['2'], 'cursor-2'), { after: 'cursor-1' }); + write(result(['2'], 'cursor-2'), { after: 'cursor-1' }); + write(result(['3'], 'cursor-3'), { after: 'unknown-cursor' }); + expect(read()?.searchAlbums.edges.map((edge) => edge.node.id)).toEqual(['1', '2']); + expect(read()?.searchAlbums.pageInfo.endCursor).toBe('cursor-2'); + }); +}); diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..ac1dc72 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,61 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; +import type { Reference, StoreObject } from '@apollo/client'; + +import type { AlbumConnection } from './searchAlbums.js'; + +// Album nodes are normalized references inside the cache, not query-result objects. +type CachedConnection = Omit & { + edges: { cursor: string; node: Reference | StoreObject }[]; +}; + +// InMemoryCache stores server results and shares album records by their IDs. +export const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + searchAlbums: { + // Each complete search input gets its own list; paging arguments do not. + keyArgs: ['input'], + + merge( + existing: CachedConnection | undefined, + incoming: CachedConnection, + { args, readField }, + ): CachedConnection { + // A first-page refresh must replace any previously accumulated pages. + if (!args?.after) return incoming; + + // Only append to the current tail. Ignore repeated or stale continuations. + if (!existing || args.after !== existing.pageInfo.endCursor) { + return existing ?? incoming; + } + + const ids = new Set( + existing.edges.map((edge) => readField('id', edge.node)), + ); + const edges = [...existing.edges]; + + for (const edge of incoming.edges) { + const id = readField('id', edge.node); + + if (!ids.has(id)) { + ids.add(id); + edges.push(edge); + } + } + + return { ...incoming, edges }; + }, + }, + }, + }, + }, +}); + +// ApolloClient coordinates requests and the cache for components under ApolloProvider. +export const apolloClient = new ApolloClient({ + cache, + // HttpLink sends GraphQL operations over HTTP, independently of the /project2/ asset base. + // TODO(#6): Verify the endpoint and any identity header against the real backend. + link: new HttpLink({ uri: import.meta.env.VITE_GRAPHQL_URL || '/graphql' }), +}); diff --git a/client/src/api/searchAlbums.ts b/client/src/api/searchAlbums.ts new file mode 100644 index 0000000..becb99c --- /dev/null +++ b/client/src/api/searchAlbums.ts @@ -0,0 +1,71 @@ +import { gql } from '@apollo/client'; +import type { TypedDocumentNode } from '@apollo/client'; + +export type AlbumSort = + 'RELEVANCE' | 'TITLE_ASC' | 'RELEASE_YEAR_DESC' | 'AVERAGE_RATING_DESC'; + +export interface AlbumSearchInput { + query: string; + genreIds?: string[] | null; + decades?: number[] | null; + sort: AlbumSort; +} + +export interface AlbumSummary { + id: string; + title: string; + releaseYear: number | null; + artists: { id: string; name: string }[]; + genres: { id: string; name: string }[]; + averageRating: number | null; + reviewCount: number; +} + +export interface AlbumConnection { + edges: { cursor: string; node: AlbumSummary }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + totalCount: number; +} + +export interface SearchAlbumsData { + searchAlbums: AlbumConnection; +} + +export interface SearchAlbumsVariables { + input: AlbumSearchInput; + first: number; + after?: string | null; +} + +// gql parses the operation; TypedDocumentNode describes its variables and selected result fields. +// Only the search-page projection is requested; details and facets belong to later work. +export const SEARCH_ALBUMS: TypedDocumentNode = + gql` + query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) { + searchAlbums(input: $input, first: $first, after: $after) { + edges { + cursor + node { + id + title + releaseYear + artists { + id + name + } + genres { + id + name + } + averageRating + reviewCount + } + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } + `; diff --git a/client/src/components/AlbumCard.css b/client/src/components/AlbumCard.css new file mode 100644 index 0000000..b9c6d5a --- /dev/null +++ b/client/src/components/AlbumCard.css @@ -0,0 +1,62 @@ +.album-card { + height: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.album-info { + height: 100%; + display: flex; + flex-direction: column; + overflow-wrap: anywhere; + padding: 1rem; +} +.album-info h3 { + margin: 0; + font-size: 1rem; + line-height: 1.4; + overflow-wrap: anywhere; +} +.artist { + margin: 0.25rem 0; + color: var(--muted); + font-size: 0.9rem; +} +.release-year { + color: var(--muted); + font-size: 0.9rem; + margin: 0.5rem 0; +} +.genre-tags { + list-style: none; + padding: 0; + margin: 0.7rem 0 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.genre-tags li { + background: #eff0e9; + color: #50574c; + border-radius: 4px; + padding: 0.15rem 0.4rem; + font-size: 0.8rem; +} +.album-rating { + margin-top: auto; + border-top: 1px solid var(--border); + padding-top: 0.7rem; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.4rem 0.75rem; + font-size: 0.875rem; +} +.album-rating > span:first-child { + color: var(--accent); + font-weight: 700; +} +.album-rating > span:last-child { + color: var(--muted); +} diff --git a/client/src/components/AlbumCard.test.tsx b/client/src/components/AlbumCard.test.tsx new file mode 100644 index 0000000..70ea9c8 --- /dev/null +++ b/client/src/components/AlbumCard.test.tsx @@ -0,0 +1,22 @@ +import { it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AlbumCard from './AlbumCard.js'; + +it('handles nullable summary fields without inventing a rating or year', () => { + render( + , + ); + expect(screen.getByText('Year unknown')).toBeInTheDocument(); + expect(screen.getByText('Not yet rated')).toBeInTheDocument(); + expect(screen.getByText('0 reviews')).toBeInTheDocument(); +}); diff --git a/client/src/components/AlbumCard.tsx b/client/src/components/AlbumCard.tsx new file mode 100644 index 0000000..20687e4 --- /dev/null +++ b/client/src/components/AlbumCard.tsx @@ -0,0 +1,35 @@ +import './AlbumCard.css'; + +import type { AlbumSummary } from '../api/searchAlbums.js'; + +export default function AlbumCard({ album }: { album: AlbumSummary }) { + return ( +
+
+

{album.title}

+

{album.artists.map((artist) => artist.name).join(', ')}

+

{album.releaseYear ?? 'Year unknown'}

+
    + {album.genres.map((genre) => ( +
  • {genre.name}
  • + ))} +
+
+ + + {album.averageRating === null ? ( + 'Not yet rated' + ) : ( + <> + Average rating: + {album.averageRating.toFixed(1)} + out of 5 + + )} + + {album.reviewCount.toLocaleString('en-US')} reviews +
+
+
+ ); +} diff --git a/client/src/components/ResultsLayout.css b/client/src/components/ResultsLayout.css new file mode 100644 index 0000000..6eec343 --- /dev/null +++ b/client/src/components/ResultsLayout.css @@ -0,0 +1,37 @@ +.results { + padding-bottom: 2.5rem; +} +.album-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.2rem; +} +.album-grid > li { + min-width: 0; +} +.empty-state { + text-align: center; + padding: 3rem 1rem; + border: 1px dashed #b7bfb3; + border-radius: 8px; +} +.empty-state p { + color: var(--muted); +} +.search-feedback p { + min-height: 3rem; + margin: 0 0 1rem; +} +@media (max-width: 1000px) { + .album-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 600px) { + .album-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/client/src/components/ResultsLayout.tsx b/client/src/components/ResultsLayout.tsx new file mode 100644 index 0000000..8b9c0e8 --- /dev/null +++ b/client/src/components/ResultsLayout.tsx @@ -0,0 +1,72 @@ +import './ResultsLayout.css'; + +import AlbumCard from './AlbumCard.js'; +import type { AlbumSummary } from '../api/searchAlbums.js'; + +interface Props { + albums: AlbumSummary[]; + totalCount: number; + hasQuery: boolean; + loading: boolean; + retrying: boolean; + error: boolean; + onRetry: () => void; + onReset: () => void; +} + +export default function ResultsLayout(props: Props) { + return ( +
+

Search results

+

+ {!props.hasQuery + ? 'Search for an album or artist to get started.' + : props.loading + ? props.retrying + ? 'Retrying search…' + : 'Searching albums…' + : props.error + ? 'Search failed.' + : `Showing ${props.albums.length} of ${props.totalCount} albums.`} +

+ {props.hasQuery && + (props.error || props.retrying ? ( +
+

+ Could not load albums. Please try again. +

+ {/* Keep the button mounted so retrying does not discard keyboard focus. */} + +
+ ) : props.loading ? null : props.totalCount === 0 ? ( +
+

No albums found

+

Try another album or artist.

+ +
+ ) : ( +
    + {props.albums.map((album) => ( +
  • + +
  • + ))} +
+ ))} +
+ ); +} diff --git a/client/src/components/SearchForm.css b/client/src/components/SearchForm.css new file mode 100644 index 0000000..bc9ce3c --- /dev/null +++ b/client/src/components/SearchForm.css @@ -0,0 +1,24 @@ +.search-form { + display: flex; + gap: 0.6rem; + max-width: 640px; + margin: auto; +} +.search-form input { + width: 100%; + min-width: 0; + padding: 0.9rem 1rem; + border: 1px solid #b7bfb3; + border-radius: 6px; + background: var(--surface); + color: var(--text); +} +.search-form input::placeholder { + color: var(--muted); + opacity: 1; +} +@media (max-width: 420px) { + .search-form { + flex-direction: column; + } +} diff --git a/client/src/components/SearchForm.test.tsx b/client/src/components/SearchForm.test.tsx new file mode 100644 index 0000000..2f06426 --- /dev/null +++ b/client/src/components/SearchForm.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import SearchForm from './SearchForm.js'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +function type(query: string) { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: query } }); +} + +function advance(milliseconds: number) { + act(() => vi.advanceTimersByTime(milliseconds)); +} + +describe('SearchForm', () => { + it('waits 300 ms after the last change and submits only the latest text', () => { + const onSearch = vi.fn(); + render(); + type('Abb'); + advance(200); + type('Abbey'); + advance(299); + expect(onSearch).not.toHaveBeenCalled(); + advance(1); + expect(onSearch).toHaveBeenCalledExactlyOnceWith('Abbey'); + }); + + it.each(['form', 'button'])( + 'submits immediately through %s and cancels the debounce', + (method) => { + const onSearch = vi.fn(); + render(); + type(' Abbey '); + if (method === 'form') fireEvent.submit(screen.getByRole('search')); + else fireEvent.click(screen.getByRole('button', { name: 'Search' })); + expect(onSearch).toHaveBeenCalledExactlyOnceWith('Abbey'); + advance(300); + expect(onSearch).toHaveBeenCalledTimes(1); + }, + ); + + it('submits immediately on Enter and cancels the debounce', () => { + const onSearch = vi.fn(); + render(); + const input = screen.getByRole('searchbox'); + input.focus(); + type(' Abbey '); + expect(onSearch).not.toHaveBeenCalled(); + + const defaultAllowed = fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + // jsdom does not perform implicit form submission for keyboard events. + if (defaultAllowed) act(() => input.form?.requestSubmit()); + fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' }); + + expect(input).toHaveFocus(); + expect(onSearch).toHaveBeenCalledExactlyOnceWith('Abbey'); + advance(300); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('cancels pending work when unmounted', () => { + const onSearch = vi.fn(); + const view = render(); + type('Abbey'); + view.unmount(); + advance(300); + expect(onSearch).not.toHaveBeenCalled(); + }); + + it('does not resubmit a committed query', () => { + const onSearch = vi.fn(); + const view = render(); + type('Abbey'); + view.rerender(); + advance(300); + expect(onSearch).not.toHaveBeenCalled(); + }); + + it('waits until composition input has finished', () => { + const onSearch = vi.fn(); + render(); + fireEvent.compositionStart(screen.getByRole('searchbox')); + type('Abbey'); + advance(300); + expect(onSearch).not.toHaveBeenCalled(); + fireEvent.compositionEnd(screen.getByRole('searchbox')); + advance(300); + expect(onSearch).toHaveBeenCalledExactlyOnceWith('Abbey'); + }); +}); diff --git a/client/src/components/SearchForm.tsx b/client/src/components/SearchForm.tsx new file mode 100644 index 0000000..333e27e --- /dev/null +++ b/client/src/components/SearchForm.tsx @@ -0,0 +1,59 @@ +import './SearchForm.css'; + +import { useEffect, useRef, useState } from 'react'; + +interface Props { + query: string; + onSearch: (query: string) => void; +} + +export default function SearchForm({ query, onSearch }: Props) { + const [draft, setDraft] = useState(query); + const [previousQuery, setPreviousQuery] = useState(query); + const [composing, setComposing] = useState(false); + const timer = useRef | undefined>(undefined); + + if (query !== previousQuery) { + setPreviousQuery(query); + setDraft(query); + } + + // Keep typing responsive; wait for a pause before committing a new query. + useEffect(() => { + if (composing || draft.trim() === query) return; + + timer.current = setTimeout(() => onSearch(draft.trim()), 300); + return () => clearTimeout(timer.current); + }, [draft, query, composing, onSearch]); + + return ( +
{ + event.preventDefault(); + if (composing) return; + + // Enter and the button bypass the delay without submitting twice. + clearTimeout(timer.current); + onSearch(draft.trim()); + }} + > + + setDraft(event.target.value)} + onCompositionStart={() => setComposing(true)} + onCompositionEnd={() => setComposing(false)} + placeholder="Search by album or artist…" + /> + +
+ ); +} diff --git a/client/src/global.css b/client/src/global.css new file mode 100644 index 0000000..49a1a38 --- /dev/null +++ b/client/src/global.css @@ -0,0 +1,91 @@ +:root { + color-scheme: light; + --bg: #f7f5ef; + --surface: #fffefa; + --text: #202824; + --muted: #61665e; + --accent: #285943; + --border: #dedfd5; + --focus: #2874c5; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + sans-serif; + line-height: 1.5; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--text); +} +a { + color: inherit; +} +button, +input, +select { + font: inherit; +} +button, +select, +input[type='checkbox'] { + cursor: pointer; +} +button { + border-radius: 6px; + padding: 0.65rem 1rem; +} +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 3px solid var(--focus); + outline-offset: 4px; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} +.eyebrow { + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.7rem; + font-weight: 700; + color: var(--accent); +} +.primary-button { + background: var(--accent); + color: white; + border: 1px solid var(--accent); +} +.primary-button:hover { + background: #1c4230; +} +.results h2 { + margin: 0; + font-size: 1.3rem; + letter-spacing: -0.025em; +} +.muted { + color: var(--muted); + font-size: 0.85rem; +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/client/src/hooks/useAlbumSearch.ts b/client/src/hooks/useAlbumSearch.ts new file mode 100644 index 0000000..d2ef36d --- /dev/null +++ b/client/src/hooks/useAlbumSearch.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef, useState } from 'react'; +import { skipToken, useQuery } from '@apollo/client/react'; +import { NetworkStatus } from '@apollo/client'; + +import { SEARCH_ALBUMS } from '../api/searchAlbums.js'; + +export function useAlbumSearch() { + const [query, setQuery] = useState(''); + const hasQuery = query.length > 0; + const retryInFlight = useRef(false); + + // Apollo owns server data; React state only holds the committed search text. + const { data, loading, error, refetch, networkStatus } = useQuery( + SEARCH_ALBUMS, + hasQuery + ? { variables: { input: { query, sort: 'RELEVANCE' }, first: 20, after: null } } + : skipToken, + ); + + const retry = useCallback(() => { + if (!hasQuery || !error || loading || retryInFlight.current) return; + + // Guard rapid clicks before Apollo publishes the next loading render. + retryInFlight.current = true; + void refetch() + .catch(() => { + // useQuery exposes the error for the UI. + }) + .finally(() => { + retryInFlight.current = false; + }); + }, [hasQuery, error, loading, refetch]); + + const search = useCallback( + (text: string) => { + const nextQuery = text.trim(); + + if (nextQuery !== query) setQuery(nextQuery); + else retry(); + }, + [query, retry], + ); + + // TODO(#6): Verify matching results against a populated database before accepting #9. + return { + query, + search, + hasQuery, + albums: hasQuery ? (data?.searchAlbums.edges.map((edge) => edge.node) ?? []) : [], + totalCount: hasQuery ? (data?.searchAlbums.totalCount ?? 0) : 0, + loading: hasQuery && loading, + // Refetch status distinguishes retries even when Apollo clears the previous error. + retrying: hasQuery && networkStatus === NetworkStatus.refetch, + error: hasQuery && Boolean(error), + retry, + }; +} diff --git a/client/src/main.tsx b/client/src/main.tsx index a1d4fe9..ab46be3 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,13 +1,19 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { ApolloProvider } from '@apollo/client/react'; + +import { apolloClient } from './api/client.js'; import App from './App.js'; -import './App.css'; + +import './global.css'; const container = document.getElementById('root'); if (!container) throw new Error('Missing #root element in index.html'); createRoot(container).render( - + + + , ); diff --git a/client/vite.config.ts b/client/vite.config.ts index a8a1993..5810ede 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -7,5 +7,10 @@ export default defineConfig({ base: '/project2/', server: { port: 5173, + proxy: { + // Matches Apache's production /graphql route; the local API uses port 3001. + // TODO(#6): Confirm this port when the backend is available in this checkout. + '/graphql': 'http://127.0.0.1:3001', + }, }, }); diff --git a/docs/images/crate-ui-concept.png b/docs/images/crate-ui-concept.png new file mode 100644 index 0000000..39fa8cb Binary files /dev/null and b/docs/images/crate-ui-concept.png differ diff --git a/docs/ui-specification.md b/docs/ui-specification.md index e81f8bf..85ebf02 100644 --- a/docs/ui-specification.md +++ b/docs/ui-specification.md @@ -1,9 +1,18 @@ # UI specification -This document describes the planned user interface for the album catalogue -using static example data. It is a lightweight specification of how albums -will be presented and how users can search, filter, sort and browse the -catalogue before the client is connected to the backend. +This document describes the planned user interface for Crate, an album catalogue application. It includes example data, wireframes and a visual design concept to guide the ongoing frontend implementation. + +The initial search interface has been implemented, while additional features and visual refinements are planned for later development stages. + +## Visual design concept + +The following concept illustrates the intended visual direction for Crate, including desktop and mobile search layouts and an album detail view. + +![Crate UI concept showing desktop and mobile layouts and an album detail page](images/crate-ui-concept.png) + +The design uses a dark navigation bar, a music-themed hero section, green accents and responsive album cards. Genre and decade filters are presented as compact dropdown controls rather than a permanent sidebar, following feedback received on the initial UI sketch. + +This is a visual reference for future development, not a representation of completed functionality. Features such as filtering, sorting, favourites, ratings, reviews and album details will be implemented incrementally in their respective issues. ## Example data @@ -26,9 +35,7 @@ and a list or grid of matching albums. ### Search -The user can search for albums by title or artist. A search is submitted by -pressing Enter or clicking the search button. The result view is then updated -with albums matching the search. +The user can search for albums by title or artist. Search results update automatically after a short debounce to avoid unnecessary requests. Pressing Enter or clicking the search button submits the search immediately. ### Filters @@ -38,6 +45,8 @@ option shows the number of matching albums. Selected filters are shown above the results and can be removed individually or cleared together. +To keep the interface compact, genre and decade filters are planned as dropdown menus rather than a permanently visible sidebar. The controls should remain accessible by keyboard and work on both desktop and mobile screens. + ### Sorting The result set can be sorted without changing the current search or filters. @@ -73,8 +82,7 @@ the search or active filters. ## Wireframe -The wireframes show the intended structure of the interface rather than final -visual styling. +The wireframes below are early structural sketches of the interface rather than final visual designs. The visual concept above reflects the updated direction, including compact dropdown filters instead of a permanent sidebar. ### Search and browse view diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70f185e..c3001c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,12 +44,21 @@ importers: client: dependencies: + '@apollo/client': + specifier: ^4.3.3 + version: 4.3.3(graphql@16.14.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rxjs@7.8.2) + graphql: + specifier: ^16.14.2 + version: 16.14.2 react: specifier: ^19.2.8 version: 19.3.0 react-dom: specifier: ^19.2.8 version: 19.3.0(react@19.3.0) + rxjs: + specifier: ^7.8.2 + version: 7.8.2 devDependencies: '@testing-library/dom': specifier: ^10.4.1 @@ -124,6 +133,25 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@apollo/client@4.3.3': + resolution: {integrity: sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==} + peerDependencies: + graphql: ^16.0.0 || ^17.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1146,6 +1174,22 @@ packages: resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==} engines: {node: '>=18.0.0'} + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1714,6 +1758,12 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graphql-tag@2.12.7: + resolution: {integrity: sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + graphql-yoga@5.24.4: resolution: {integrity: sha512-jnpLm2g9BfnP18VKL+Yao8YRQYgDjXdAkFgZso/tnKFcRcAtOeUKLDVc1XnYiE3uooXzfIQzsvpKR+fC3we4QA==} engines: {node: '>=18.0.0'} @@ -2098,6 +2148,9 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2254,6 +2307,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -2637,6 +2693,21 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@apollo/client@4.3.3(graphql@16.14.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rxjs@7.8.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.14.2 + graphql-tag: 2.12.7(graphql@16.14.2) + optimism: 0.18.1 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -3470,6 +3541,22 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@wry/caches@1.0.1': + dependencies: + tslib: 2.8.1 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.8.1 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.8.1 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.8.1 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -4109,6 +4196,11 @@ snapshots: gopd@1.2.0: {} + graphql-tag@2.12.7(graphql@16.14.2): + dependencies: + graphql: 16.14.2 + tslib: 2.8.1 + graphql-yoga@5.24.4(graphql@16.14.2): dependencies: '@envelop/core': 5.6.1 @@ -4476,6 +4568,13 @@ snapshots: obug@2.2.1: {} + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.8.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4644,6 +4743,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.11 '@rolldown/binding-win32-x64-msvc': 1.2.11 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9