Build a Streaming Catalog with Cursor

Build a React catalog with Cursor, live filters, and a persistent list.

Introduction

30 Second Summary

Choosing something to watch can turn into more scrolling than watching. A useful catalog makes the next title easy to spot and easy to save for later.

In this project, you will build Lumi, an original streaming catalog powered by React. You'll use Cursor Agent to plan changes before reviewing the proposed code.

What You'll Build

You'll open Lumi in Safari as a responsive streaming catalog where searches, filters, and saved titles update on screen.

By the end of this project, you'll have:

  • A responsive streaming catalog with a cinematic hero plus scrolling genre rows that work at desktop and mobile widths.
  • Live title and genre discovery that updates the visible catalog as you search or choose a filter.
  • A persistent My List that updates its count immediately and keeps saved titles after a browser refresh.
  • Secret Mission: Add a Surprise Me button that changes the featured title on every click.

Are there any prerequisites?

No coding experience is required because each concept is explained as you build. You need Cursor installed and signed in on a Mac with Node.js, npm, and Safari available.

Before We Start

Your first commitment is to build Lumi as an original streaming catalog that leaves Tubi's branding, artwork, licensed titles, and code outside the project. Lumi's visitor experience has four goals: browse fictional titles, search the catalog, filter by genre, and keep My List after a refresh.

Set Up Lumi in Cursor

Lumi needs a predictable local workspace before its streaming interface can take shape. A version check now prevents confusing dependency errors once the project becomes more complex.

You will prepare the project in Cursor. You will use Node.js to run a pinned React workspace powered by Vite. The first screen stays tiny so you can prove the complete toolchain works.

In this step, get ready to:
  • Check that your installed Node.js version meets Vite's requirement.
  • Create the pinned Lumi workspace inside lumi-streaming.
  • Install the dependencies and launch the Lumi setup screen.
Check your Node.js version

Vite checks your Node.js version before it starts. This project needs Node.js 20.19+ or 22.12+.

  • Press Cmd+Space to open macOS search.
  • Type Cursor into the search field.
  • Press Enter to open Cursor.
  • Select Terminal from Cursor's top menu.
  • Select New Terminal to open the terminal panel.
  • Check your installed Node.js version by running:
node -v

✔️ I see a compatible version

Good, your Node.js version clears Vite's compatibility check. Continue when the output meets 20.19+ or 22.12+.

ⓧ I see an older version

Your current version cannot run this pinned Vite workspace. Install Node.js 24.20.0 LTS before creating the project.

  • Visit the official Node.js download page.
  • Download the macOS installer for Node.js 24.20.0 LTS.
  • Open the downloaded installer package.
  • Complete the installer prompts using the default options.
  • Quit Cursor after the installation finishes.
  • Reopen Cursor through macOS search.
  • Return to Cursor's terminal panel.
  • Confirm the updated version by running:
node -v

You should now see Node.js 24.20.0.

ⓧ Command not found

Cursor cannot currently reach your Node.js installation. Reinstalling the current LTS version gives the reopened app a fresh command path.

  • Visit the official Node.js download page.
  • Download the macOS installer for Node.js 24.20.0 LTS.
  • Open the downloaded installer package.
  • Complete the installer prompts using the default options.
  • Quit Cursor after the installation finishes.
  • Reopen Cursor through macOS search.
  • Return to Cursor's terminal panel.
  • Check the command again by running:
node -v

You should now see Node.js 24.20.0.

Still unable to check Node.js?

Confirm that you fully quit Cursor after installing Node.js. A window left open during installation can keep the previous command path.

If the check still fails, help me diagnose why Cursor cannot find Node.js after I installed it on macOS.

Create the Lumi workspace

A React workspace separates the package manifest, browser document, application entry point, component, and styles. Keeping these roles distinct makes later changes easier to review.

  • Create the lumi-streaming folder on your Desktop by running these commands:
cd ~/Desktop
mkdir lumi-streaming
ls
cd lumi-streaming

What do these commands do?

  • The first command moves the terminal to your Desktop.
  • The second command creates the lumi-streaming folder.
  • The final commands confirm the folder exists before moving the terminal into it.

You will see lumi-streaming in the Desktop listing.

Folder already exists?

A message about an existing folder means another lumi-streaming folder is already on your Desktop. Check whether it contains work you want to keep before continuing.

If you are unsure what the message means, help me inspect an existing Lumi folder without deleting anything.

  • Select File from Cursor's top menu.
  • Select Open Folder.
  • Choose the lumi-streaming folder on your Desktop.
  • Confirm the folder selection.
  • Return to Cursor's terminal panel after the folder opens.
  • Create the project file structure by running:
mkdir src
touch package.json vite.config.js index.html src/main.jsx src/App.jsx src/styles.css
ls
ls src

What does this create?

The src folder holds the application files. The top-level files describe the package, configure Vite, and provide the browser document.

The first listing shows index.html, package.json, src, and vite.config.js. The second listing shows the three source files.

Files missing from the listing?

Confirm that Cursor's terminal is inside the Desktop lumi-streaming folder. A different location creates the files somewhere else.

For help tracing the folder, help me find where my Lumi project files were created.

The package.json manifest pins every dependency used by this project. Pinning protects your workspace from unexpected package updates.

  • Select package.json in Cursor's Explorer sidebar.
  • Replace its contents with this manifest:
{
  "name": "lumi-streaming",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}

What does this manifest control?

The scripts define the development, production build, and preview commands. The dependency sections lock React, React DOM, Vite, and the Vite React plugin to compatible versions.

  • Save package.json.
  • Confirm the editor shows the three Vite scripts and four pinned packages.
  • Select vite.config.js in Cursor's Explorer sidebar.
  • Replace its contents with this configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

How is Vite configured?

The configuration imports Vite's configuration helper. It activates the React plugin so Vite can process JSX files.

  • Save vite.config.js.
  • Confirm the editor shows react() inside the plugin list.
  • Display both saved files in the terminal by running:
cat package.json vite.config.js

The terminal prints the complete manifest followed by the Vite configuration. This confirms both saved files contain the pinned setup.

Seeing a manifest or configuration error?

Compare every quote, comma, brace, and package version with the code above. A missing comma in package.json prevents the package manager from reading the manifest.

For a focused comparison, help me find the syntax difference in my Lumi package or Vite configuration.

The browser document provides one HTML element for React to control. The setup component supplies the first visible proof that rendering works.

  • Select index.html in Cursor's Explorer sidebar.
  • Replace its contents with this document:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Lumi is an original streaming discovery interface built with React."
    />
    <title>Lumi Streaming</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

How does the document start Lumi?

The metadata gives the page its language, mobile viewport, description, and title. The root element becomes React's mounting point.

The module script loads src/main.jsx when the browser opens the page.

  • Save index.html.
  • Confirm the editor shows Lumi Streaming as the page title.
  • Select src/App.jsx in Cursor's Explorer sidebar.
  • Replace its contents with this setup component:
export default function App() {
  return <h1>Lumi setup ready.</h1>
}

What does the component return?

The App component returns one heading as JSX. Exporting it as the default lets the entry file import it directly.

  • Save src/App.jsx.
  • Confirm the component returns the exact text Lumi setup ready..
  • Display both saved files in the terminal by running:
cat index.html src/App.jsx

The terminal prints the browser document followed by the setup component. You now have the page shell and its first visible content.

Setup files look incomplete?

Check that index.html points to /src/main.jsx. Confirm that App is exported as the default component.

If the files still differ, help me compare my Lumi HTML document and setup component.

The entry file connects the browser document to the component. A small CSS foundation makes the first screen readable while keeping the full visual design for the next step.

  • Select src/main.jsx in Cursor's Explorer sidebar.
  • Replace its contents with this entry file:
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'

createRoot(document.getElementById('root')).render(<App />)

How does React reach the page?

The imports load React's client renderer, the App component, and the stylesheet. createRoot takes control of the HTML element whose ID is root.

  • Save src/main.jsx.
  • Confirm the final line renders <App /> into #root.
  • Select src/styles.css in Cursor's Explorer sidebar.
  • Replace its contents with these setup styles:
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #f8fafc;
  background: #08050d;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: #08050d;
}

What do the setup styles change?

The root styles choose a readable system font with light text on Lumi's dark background. The body rules remove the browser margin and keep the page at least as tall as the screen.

  • Save src/styles.css.
  • Confirm the stylesheet includes the dark #08050d background.
  • Display both saved source files in the terminal by running:
cat src/main.jsx src/styles.css

The terminal prints the React entry point followed by the setup stylesheet. Every source file now contains its intended starting code.

Entry files look different?

Check the capitalization in ./App.jsx. File paths on some systems treat uppercase and lowercase letters as different names.

For help finding a mismatch, help me compare my Lumi entry file and stylesheet.

Install and run Lumi

The manifest names the packages Lumi needs. The npm installation turns that manifest into local dependencies plus a lock file that records the resolved package tree.

  • Install the pinned project dependencies by running:
npm install

The terminal finishes without an installation error. Cursor's Explorer now shows node_modules plus package-lock.json.

Dependency installation failed?

Check that the terminal is inside lumi-streaming. Confirm that package.json matches the pinned manifest.

If the output names another problem, help me diagnose my Lumi npm installation error.

Vite's development server serves Lumi over HTTP and watches the source files for changes. The command keeps this terminal busy until you stop it with Control+C.

Before you run the server, what message do you expect the React component to place inside the browser's root element?

  • Start the Vite development server by running:
npm run dev

The terminal shows the local development URL http://localhost:5173. Leave this terminal running.

  • Press Cmd+Space to open macOS search.
  • Type Safari into the search field.
  • Press Enter to open Safari.
  • Enter http://localhost:5173 in Safari's address field.
  • Press Enter to load Lumi.

You will see “Lumi setup ready.” in light text on a dark page. That first React render proves the browser document, entry file, component, stylesheet, packages, and development server are connected.

Lumi page not loading?

Confirm the Vite terminal is still running. Check that Safari uses http://localhost:5173 instead of opening index.html directly.

If Safari shows an error or a blank page, help me diagnose why my Lumi Vite page is not rendering.

Use this checkpoint to compare every authored file before reviewing the final diff.

✔️ Awesome, I've got everything!

Your setup files are saved. Keep the Vite development server running for the browser check.

ⓧ I'd like to double check the full code

{
  "name": "lumi-streaming",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Lumi is an original streaming discovery interface built with React."
    />
    <title>Lumi Streaming</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
export default function App() {
  return <h1>Lumi setup ready.</h1>
}
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'

createRoot(document.getElementById('root')).render(<App />)
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #f8fafc;
  background: #08050d;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: #08050d;
}
  • Return to the Cursor window containing lumi-streaming.
  • Review each changed project file in Cursor's diff view.
  • Compare the authored files with the full-code checkpoint.
  • Confirm that package-lock.json and node_modules are the expected installation outputs.
  • Confirm that no unrelated files were changed.

Before the final refresh, do you expect the setup message to disappear or remain connected to App?

  • Return to Safari.
  • Refresh http://localhost:5173.

You will still see “Lumi setup ready.” on the dark page. Your workspace now has a repeatable path from App through createRoot to #root.

Your Lumi workspace is running with pinned dependencies and a reviewed starting point. Next, you will replace the setup message with the streaming home screen.

Build the Streaming Home Screen

Your running Vite workspace already proves that React can render Lumi in Safari. The next challenge is turning the setup message into a recognizable streaming catalog.

This step focuses on the visual home screen. You will create the hero, navigation, discovery controls, My List panel, and four movie rows.

In this step, get ready to:
  • Plan an original streaming interface with Cursor Agent.
  • Review every generated file through Cursor's diff view.
  • Test the responsive layout and discovery controls in Safari.
Plan the interface with Cursor Agent

Cursor Agent can inspect your existing project before proposing edits. Plan Mode turns that inspection into a file-by-file plan that waits for your approval.

  • Return to Cursor with the lumi-streaming folder from the previous step.
  • Press Cmd+I to open Agent in the sidepane.
  • Press Shift+Tab in the Agent input to activate Plan Mode.
  • Ask Agent to plan the Lumi home screen by pasting this prompt:
Plan an original React streaming home screen for Lumi using src/movies.js, src/App.jsx, and src/styles.css.

Use 12 fictional titles with CSS-generated poster art. Use semantic HTML with no remote assets.

Include a top navigation bar, a featured hero, a My List placeholder, a search field, genre chips, and four static genre rows rendered with array mapping.

Make the layout responsive for desktop and narrow Safari windows.

What does this prompt establish?

  • The src/movies.js file keeps the fictional catalog separate from the interface.
  • The src/App.jsx file turns that data into reusable JSX components.
  • The src/styles.css file creates the posters and responsive layout with CSS.
  • The no-remote-assets requirement keeps every title and visual inside your local project.
  • Read the proposed plan in Agent.
  • Confirm that the plan changes only src/movies.js, src/App.jsx, and src/styles.css.
  • Approve the plan so Agent can create the home screen.

You will see Agent work through the three planned files. The proposed edits remain available for review before you accept them.

Agent did not produce a plan?

Check that Plan Mode is active in the Agent input. Paste the prompt again if Agent starts editing before presenting its plan.

Use this if the workflow still looks different: Help me confirm that Cursor Agent is in Plan Mode before it edits my Lumi files.

Review the generated home screen

A diff shows the exact lines Agent proposes adding or removing. Reviewing it keeps you in control of the project instead of treating generated code as automatically correct.

  • Wait for Agent to finish preparing the three files.
  • Review every proposed change in Cursor's diff view.
  • Confirm that every movie title is fictional.
  • Confirm that every poster uses a CSS gradient.
  • Compare the proposed files with the reference versions below.

✔️ Awesome, I've got everything!

Your proposed diff matches the Lumi reference files. Return to Cursor's diff view to accept the changes.

ⓧ I'd like to double check the full code

Check src/movies.js
export const movies = [
  {
    id: 1,
    title: 'Neon Harbor',
    genre: 'Sci-Fi',
    year: '2026',
    rating: 'PG-13',
    tagline: 'The city remembers every signal.',
    description: 'A radio engineer follows a mysterious broadcast through a glowing coastal city.',
    poster: 'linear-gradient(145deg, #8b5cf6, #111827)',
  },
  {
    id: 2,
    title: 'Afterglow',
    genre: 'Drama',
    year: '2025',
    rating: 'PG',
    tagline: 'Some goodbyes light the way home.',
    description: 'Two estranged siblings rebuild their family observatory after a summer storm.',
    poster: 'linear-gradient(145deg, #f97316, #431407)',
  },
  {
    id: 3,
    title: 'Wild Signal',
    genre: 'Thriller',
    year: '2026',
    rating: 'PG-13',
    tagline: 'The mountain is listening.',
    description: 'A rescue team discovers that an emergency beacon is transmitting from an empty valley.',
    poster: 'linear-gradient(145deg, #22c55e, #052e16)',
  },
  {
    id: 4,
    title: 'Paper Planets',
    genre: 'Family',
    year: '2024',
    rating: 'G',
    tagline: 'A small fold can open a big world.',
    description: 'A young inventor builds a paper solar system that comes alive after midnight.',
    poster: 'linear-gradient(145deg, #38bdf8, #172554)',
  },
  {
    id: 5,
    title: 'Midnight Circuit',
    genre: 'Sci-Fi',
    year: '2025',
    rating: 'PG-13',
    tagline: 'One final lap can rewrite tomorrow.',
    description: 'Underground racers compete through a city where every street changes at midnight.',
    poster: 'linear-gradient(145deg, #ec4899, #3b0764)',
  },
  {
    id: 6,
    title: 'The Last Orchard',
    genre: 'Drama',
    year: '2026',
    rating: 'PG',
    tagline: 'What we grow can bring us back.',
    description: 'A chef returns home to save the orchard that shaped three generations of her family.',
    poster: 'linear-gradient(145deg, #eab308, #3f6212)',
  },
  {
    id: 7,
    title: 'Static Bloom',
    genre: 'Thriller',
    year: '2025',
    rating: 'PG-13',
    tagline: 'Every picture hides a second story.',
    description: 'A photographer notices the same unknown figure appearing in images taken years apart.',
    poster: 'linear-gradient(145deg, #64748b, #020617)',
  },
  {
    id: 8,
    title: 'Pocket Giants',
    genre: 'Family',
    year: '2026',
    rating: 'G',
    tagline: 'Big adventures come in tiny packages.',
    description: 'Three miniature explorers turn a neighborhood garden into an enormous expedition.',
    poster: 'linear-gradient(145deg, #14b8a6, #134e4a)',
  },
  {
    id: 9,
    title: 'Sunken Sky',
    genre: 'Sci-Fi',
    year: '2024',
    rating: 'PG',
    tagline: 'The stars are closer beneath the waves.',
    description: 'An ocean cartographer finds a submerged station mapping constellations from below.',
    poster: 'linear-gradient(145deg, #0ea5e9, #1e1b4b)',
  },
  {
    id: 10,
    title: 'Second Sunrise',
    genre: 'Drama',
    year: '2025',
    rating: 'PG',
    tagline: 'A new morning begins with one brave choice.',
    description: 'A former musician finds an unexpected stage in a quiet desert town.',
    poster: 'linear-gradient(145deg, #fb7185, #7c2d12)',
  },
  {
    id: 11,
    title: 'Cold Frequency',
    genre: 'Thriller',
    year: '2026',
    rating: 'PG-13',
    tagline: 'Do not answer the final transmission.',
    description: 'A polar research crew receives a warning broadcast in their own voices.',
    poster: 'linear-gradient(145deg, #67e8f9, #164e63)',
  },
  {
    id: 12,
    title: 'Cloud Parade',
    genre: 'Family',
    year: '2025',
    rating: 'G',
    tagline: 'Look up. The celebration has begun.',
    description: 'A shy kite maker helps a floating village prepare for its biggest festival.',
    poster: 'linear-gradient(145deg, #f9a8d4, #1d4ed8)',
  },
]

What should match in the movie data?

The array contains 12 objects. Each object provides the text and CSS gradient required to render one original movie card.

Check src/App.jsx
import { movies } from './movies.js'

const genres = ['All', ...new Set(movies.map((movie) => movie.genre))]

function MovieCard({ movie }) {
  return (
    <article className="movie-card">
      <div className="poster" style={{ background: movie.poster }}>
        <span className="poster-genre">{movie.genre}</span>
        <strong>{movie.title}</strong>
      </div>
      <div className="card-details">
        <div>
          <h3>{movie.title}</h3>
          <p>{movie.year} · {movie.rating}</p>
        </div>
        <button className="save-button" type="button">
          + My List
        </button>
      </div>
    </article>
  )
}

function MovieRow({ title, items }) {
  return (
    <section className="row-section" aria-labelledby={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>
      <div className="section-heading">
        <h2 id={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>{title}</h2>
        <span>{items.length} titles</span>
      </div>
      <div className="movie-row">
        {items.map((movie) => (
          <MovieCard key={movie.id} movie={movie} />
        ))}
      </div>
    </section>
  )
}

export default function App() {
  const featuredMovie = movies[0]

  return (
    <div className="app-shell">
      <header className="topbar">
        <a className="brand" href="#top" aria-label="Lumi home">
          <span>L</span>
          lumi
        </a>
        <nav aria-label="Primary navigation">
          <a href="#browse">Browse</a>
          <a href="#my-list">My List <span className="count-badge">0</span></a>
        </nav>
      </header>

      <main id="top">
        <section className="hero" style={{ background: featuredMovie.poster }}>
          <div className="hero-shade"></div>
          <div className="hero-content">
            <p className="eyebrow">Lumi featured</p>
            <h1>{featuredMovie.title}</h1>
            <p className="hero-tagline">{featuredMovie.tagline}</p>
            <p className="hero-description">{featuredMovie.description}</p>
            <div className="hero-meta">
              <span>{featuredMovie.year}</span>
              <span>{featuredMovie.rating}</span>
              <span>{featuredMovie.genre}</span>
            </div>
            <div className="hero-actions">
              <a className="primary-button" href="#browse">Browse catalog</a>
              <a className="secondary-button" href="#my-list">Open My List</a>
            </div>
          </div>
        </section>

        <section className="discovery" id="browse" aria-labelledby="discovery-title">
          <div>
            <p className="eyebrow">Explore</p>
            <h2 id="discovery-title">Find your next watch</h2>
          </div>
          <label className="search-box" htmlFor="catalog-search">
            <span>Search titles or genres</span>
            <input
              id="catalog-search"
              type="search"
              placeholder="Try Neon or Sci-Fi"
            />
          </label>
          <div className="genre-filters" aria-label="Filter by genre">
            {genres.map((genre) => (
              <button
                key={genre}
                type="button"
                className={genre === 'All' ? 'genre-chip active' : 'genre-chip'}
                aria-pressed={genre === 'All'}
              >
                {genre}
              </button>
            ))}
          </div>
        </section>

        <section className="my-list-panel" id="my-list" aria-labelledby="my-list-title">
          <div className="section-heading">
            <h2 id="my-list-title">My List</h2>
            <span>0 saved</span>
          </div>
          <p className="empty-message">Save a title and it will appear here, even after a refresh.</p>
        </section>

        <section className="catalog" aria-label="Movie catalog">
          {genres.slice(1).map((genre) => (
            <MovieRow
              key={genre}
              title={genre}
              items={movies.filter((movie) => movie.genre === genre)}
            />
          ))}
        </section>
      </main>

      <footer>
        <p>Lumi is a fictional learning project with original titles and artwork.</p>
      </footer>
    </div>
  )
}

What should match in the interface?

The MovieCard component renders one title. The MovieRow component maps a genre's movies into a horizontal row.

The App component uses movies[0] for the Neon Harbor hero. It maps the remaining genres into four static catalog rows.

Check src/styles.css
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #f8fafc;
  background: #08050d;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: #08050d;
}

button,
input {
  font: inherit;
}

a {
  color: inherit;
  text-decoration: none;
}

button,
a {
  -webkit-tap-highlight-color: transparent;
}

button:focus-visible,
a:focus-visible,
input:focus-visible {
  outline: 3px solid #facc15;
  outline-offset: 3px;
}

.app-shell {
  min-height: 100vh;
  overflow: hidden;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 70px;
  padding: 0 5vw;
  background: rgba(8, 5, 13, 0.9);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(18px);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  font-size: 1.35rem;
  font-weight: 900;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.brand > span {
  display: grid;
  width: 34px;
  height: 34px;
  place-items: center;
  color: #13051d;
  background: #facc15;
  border-radius: 50% 50% 50% 14%;
}

.topbar nav {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  color: #d8b4fe;
  font-size: 0.95rem;
  font-weight: 700;
}

.count-badge {
  display: inline-grid;
  min-width: 1.5rem;
  height: 1.5rem;
  margin-left: 0.25rem;
  place-items: center;
  color: #1c0b2b;
  background: #facc15;
  border-radius: 999px;
  font-size: 0.75rem;
}

.hero {
  position: relative;
  display: grid;
  min-height: 570px;
  align-items: end;
  isolation: isolate;
  background-size: cover !important;
}

.hero::after {
  position: absolute;
  z-index: -1;
  inset: 0;
  background:
    radial-gradient(circle at 75% 34%, transparent 0 12%, rgba(8, 5, 13, 0.22) 38%),
    linear-gradient(90deg, rgba(8, 5, 13, 0.98) 0%, rgba(8, 5, 13, 0.56) 55%, rgba(8, 5, 13, 0.18) 100%);
  content: "";
}

.hero-shade {
  position: absolute;
  z-index: -1;
  right: 8vw;
  width: min(34vw, 430px);
  aspect-ratio: 2 / 3;
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 1.2rem;
  box-shadow: 0 32px 80px rgba(0, 0, 0, 0.48);
  transform: rotate(5deg);
}

.hero-content {
  width: min(620px, 90vw);
  padding: 7rem 5vw 5rem;
}

.eyebrow {
  margin: 0 0 0.6rem;
  color: #facc15;
  font-size: 0.76rem;
  font-weight: 900;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

.hero h1 {
  margin: 0;
  font-size: clamp(3.4rem, 9vw, 7rem);
  line-height: 0.9;
  letter-spacing: -0.06em;
}

.hero-tagline {
  margin: 1.2rem 0 0;
  color: #e9d5ff;
  font-size: 1.15rem;
  font-weight: 800;
}

.hero-description {
  max-width: 54ch;
  color: #d1d5db;
  line-height: 1.65;
}

.hero-meta,
.hero-actions,
.genre-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.65rem;
}

.hero-meta span {
  padding: 0.34rem 0.62rem;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.13);
  border-radius: 999px;
  font-size: 0.78rem;
}

.hero-actions {
  margin-top: 1.6rem;
}

.primary-button,
.secondary-button {
  display: inline-flex;
  min-height: 44px;
  align-items: center;
  justify-content: center;
  padding: 0.8rem 1rem;
  border: 1px solid transparent;
  border-radius: 0.7rem;
  cursor: pointer;
  font-weight: 850;
}

.primary-button {
  color: #190b22;
  background: #facc15;
}

.secondary-button {
  color: #f8fafc;
  background: rgba(255, 255, 255, 0.1);
  border-color: rgba(255, 255, 255, 0.18);
}

.discovery,
.my-list-panel,
.catalog,
footer {
  padding-right: 5vw;
  padding-left: 5vw;
}

.discovery {
  display: grid;
  grid-template-columns: minmax(220px, 0.8fr) minmax(280px, 1.2fr);
  gap: 1rem 2rem;
  align-items: end;
  padding-top: 3.4rem;
  padding-bottom: 2.2rem;
}

.discovery h2,
.section-heading h2,
.no-results h2 {
  margin: 0;
  font-size: clamp(1.45rem, 3vw, 2.1rem);
}

.search-box {
  display: grid;
  gap: 0.45rem;
  color: #c4b5fd;
  font-size: 0.82rem;
  font-weight: 700;
}

.search-box input {
  width: 100%;
  min-height: 48px;
  padding: 0 1rem;
  color: #f8fafc;
  background: #171020;
  border: 1px solid #3b2a48;
  border-radius: 0.8rem;
}

.genre-filters {
  grid-column: 1 / -1;
}

.genre-chip,
.save-button {
  color: #e5e7eb;
  background: #171020;
  border: 1px solid #3b2a48;
  cursor: pointer;
}

.genre-chip {
  padding: 0.62rem 0.9rem;
  border-radius: 999px;
}

.genre-chip.active,
.save-button[aria-pressed="true"] {
  color: #1c0b2b;
  background: #facc15;
  border-color: #facc15;
}

.my-list-panel {
  padding-top: 1.5rem;
  padding-bottom: 2.4rem;
  background: linear-gradient(90deg, rgba(124, 58, 237, 0.15), transparent);
  border-top: 1px solid rgba(255, 255, 255, 0.07);
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}

.section-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}

.section-heading > span {
  color: #9ca3af;
  font-size: 0.82rem;
}

.empty-message,
.no-results p,
footer {
  color: #9ca3af;
}

.catalog {
  padding-top: 2.4rem;
  padding-bottom: 3.4rem;
}

.row-section + .row-section {
  margin-top: 2.8rem;
}

.movie-row {
  display: grid;
  grid-auto-columns: minmax(160px, 190px);
  grid-auto-flow: column;
  gap: 1rem;
  overflow-x: auto;
  padding: 0.25rem 0 1rem;
  overscroll-behavior-inline: contain;
  scrollbar-color: #6d28d9 #171020;
}

.movie-card {
  min-width: 0;
}

.poster {
  display: flex;
  aspect-ratio: 2 / 3;
  flex-direction: column;
  justify-content: space-between;
  padding: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 0.9rem;
  box-shadow: 0 18px 38px rgba(0, 0, 0, 0.28);
  transition: transform 180ms ease, border-color 180ms ease;
}

.movie-card:hover .poster {
  border-color: #facc15;
  transform: translateY(-5px);
}

.poster-genre {
  align-self: flex-start;
  padding: 0.3rem 0.48rem;
  background: rgba(0, 0, 0, 0.38);
  border-radius: 999px;
  font-size: 0.7rem;
  font-weight: 800;
}

.poster strong {
  font-size: 1.45rem;
  line-height: 1;
  text-shadow: 0 2px 18px rgba(0, 0, 0, 0.7);
}

.card-details {
  display: grid;
  gap: 0.75rem;
  padding: 0.75rem 0.12rem 0;
}

.card-details h3,
.card-details p {
  margin: 0;
}

.card-details h3 {
  font-size: 0.98rem;
}

.card-details p {
  margin-top: 0.25rem;
  color: #9ca3af;
  font-size: 0.78rem;
}

.save-button {
  min-height: 38px;
  padding: 0.55rem 0.7rem;
  border-radius: 0.62rem;
  font-size: 0.78rem;
  font-weight: 800;
}

.no-results {
  padding: 3rem 1rem;
  text-align: center;
  background: #120c19;
  border: 1px dashed #4c3560;
  border-radius: 1rem;
}

footer {
  padding-top: 1.5rem;
  padding-bottom: 2.5rem;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 0.82rem;
}

@media (max-width: 720px) {
  .topbar {
    min-height: 62px;
  }

  .topbar nav a:first-child {
    display: none;
  }

  .hero {
    min-height: 500px;
  }

  .hero-shade {
    right: -12vw;
    bottom: 6rem;
    width: 52vw;
    opacity: 0.45;
  }

  .hero-content {
    padding-top: 5rem;
    padding-bottom: 3.5rem;
  }

  .hero-description {
    max-width: 38ch;
  }

  .discovery {
    grid-template-columns: 1fr;
  }

  .genre-filters {
    grid-column: auto;
  }

  .movie-row {
    grid-auto-columns: minmax(145px, 44vw);
  }
}

What should match in the stylesheet?

The stylesheet covers the dark palette, sticky navigation, hero, discovery controls, My List panel, movie cards, and footer. The final media query reorganizes the layout when the viewport is 720px wide or narrower.

  • Return to Cursor's diff view.
  • Accept the proposed changes after all three files match the reference versions.
Test the responsive screen

The responsive rules change the layout when Safari becomes narrow. This check proves that the same components remain usable across desktop and mobile-sized widths.

  • Switch back to the Safari window from the previous step.
  • Refresh http://localhost:5173.

You will see the Neon Harbor hero above the discovery controls. Below it, you will see the My List placeholder and four horizontally scrolling genre rows.

  • Narrow the Safari window until it resembles a phone-sized screen.

You will see the discovery controls stack into one column. The movie cards remain readable inside horizontally scrolling rows.

Before you test the search field, do you expect typing a title to change the cards on screen?

  • Click the search field under Find your next watch.
  • Type neon into the search field.

The input displays neon, but all four genre rows remain visible. That shortfall is intentional because the control has no React state connecting it to the cards yet.

Why does the search field fall short?

The browser can collect text in an input without changing the rest of the page. React needs state and filtering logic before that text can control which movie objects are rendered.

That is the visual foundation complete: Lumi now looks like a responsive streaming catalog. Next, you will connect the discovery controls to React state so the visible cards respond to each search and genre choice.

Turn On Search and Genre Filtering

Your Lumi home screen already looks like a streaming catalog. Its search field and genre chips now need to control the cards visitors see.

In this step, React state connects each discovery control to the catalog. Filtering then turns the current search text and selected genre into a focused set of results.

In this step, get ready to:
  • Store the current search text and selected genre in state.
  • Filter the movie data using both discovery controls.
  • Render matching cards or a clear no-results message.
Create state for discovery

State gives a component memory. Lumi uses one state value for the search text and another for the selected genre.

  • In Cursor, return to src/App.jsx.
  • Replace the current import area with this code:
import { useState } from 'react'
import { movies } from './movies.js'

What Do These Imports Provide?

  • The useState import gives App a way to remember changing discovery values.
  • The movies import remains the source data that Lumi searches.
  • Find the featuredMovie line inside App.
  • Add the two state declarations directly above that line by copying this code:
  const [query, setQuery] = useState('')
  const [activeGenre, setActiveGenre] = useState('All')
  const featuredMovie = movies[0]

How Does This State Work?

  • The query value starts as an empty string because the visitor has not searched yet.
  • The activeGenre value starts as All so every genre remains available.
  • The setter functions update those values after the visitor uses a control.
  • Save src/App.jsx.
  • Switch back to Safari.

You should still see the Lumi hero and all four genre rows. This confirms that the new state declarations compile inside the running app.

Seeing an Import or State Error?

Check that useState uses the same capitalization in the import and both state declarations.

Confirm that both declarations sit inside the opening lines of the App function.

Help me fix my React state setup.

Filter movies from the current controls

The catalog needs one derived array that responds to both state values. Each movie stays in that array only when it matches the current text and genre checks.

  • In src/App.jsx, find const featuredMovie = movies[0].
  • Add the filtering logic directly below that line by copying this code:
  const cleanedQuery = query.trim().toLowerCase()
  const filteredMovies = movies.filter((movie) => {
    const matchesText =
      movie.title.toLowerCase().includes(cleanedQuery) ||
      movie.genre.toLowerCase().includes(cleanedQuery)
    const matchesGenre = activeGenre === 'All' || movie.genre === activeGenre
    return matchesText && matchesGenre
  })
  const isDiscovering = cleanedQuery.length > 0 || activeGenre !== 'All'

How Does the Filter Decide?

  • The cleanedQuery value removes surrounding spaces and normalizes capitalization.
  • The matchesText check accepts a matching title or genre.
  • The matchesGenre check accepts every movie while All is active.
  • The isDiscovering value records whether either discovery control is active.
  • Save src/App.jsx.
  • Return to Safari.

You should still see the complete catalog without an error screen. Lumi can now calculate matching movies whenever either state value changes.

Seeing a Filtering Error?

Check that filteredMovies uses movies.filter() with a closing brace and parenthesis.

Confirm that matchesText and matchesGenre are declared inside the filter callback.

Help me debug my movie filter.

Connect controls to visible results

A controlled input displays its state value and updates that value after every edit. The genre buttons use the same pattern through click handlers.

  • Find the input with the catalog-search ID.
  • Replace the complete input element with this code:
            <input
              id="catalog-search"
              type="search"
              value={query}
              placeholder="Try Neon or Sci-Fi"
              onChange={(event) => setQuery(event.target.value)}
            />

What Makes the Input Controlled?

  • The value prop displays the current query state.
  • The onChange handler stores each new input value through setQuery.
  • Find the genres.map() block inside genre-filters.
  • Replace that complete mapping block with this code:
            {genres.map((genre) => (
              <button
                key={genre}
                type="button"
                className={activeGenre === genre ? 'genre-chip active' : 'genre-chip'}
                aria-pressed={activeGenre === genre}
                onClick={() => setActiveGenre(genre)}
              >
                {genre}
              </button>
            ))}

How Do the Genre Buttons Respond?

  • Each click sends that button's genre to setActiveGenre.
  • The matching button receives the active class for its highlighted style.
  • The aria-pressed value exposes the selected state to assistive technology.
  • Save src/App.jsx.
  • Switch back to Safari.
  • Select the Sci-Fi genre chip.

The Sci-Fi chip should turn yellow. This confirms that the button updates activeGenre and reflects its selected state.

Genre Chip Not Highlighting?

Check that the button calls setActiveGenre(genre) inside its click handler.

Confirm that the active class expression compares activeGenre with genre.

Help me fix the active genre button.

  • Find the complete catalog section near the bottom of App.
  • Replace that section with this conditional catalog:
        <section className="catalog" aria-label="Movie catalog">
          {isDiscovering ? (
            filteredMovies.length > 0 ? (
              <MovieRow
                title="Discovery Results"
                items={filteredMovies}
              />
            ) : (
              <div className="no-results">
                <h2>No titles found</h2>
                <p>Try a different title or choose another genre.</p>
              </div>
            )
          ) : (
            genres.slice(1).map((genre) => (
              <MovieRow
                key={genre}
                title={genre}
                items={movies.filter((movie) => movie.genre === genre)}
              />
            ))
          )}
        </section>

How Does the Catalog Switch Views?

  • An active discovery control renders one Discovery Results row from filteredMovies.
  • An empty filtered array renders the no-results message.
  • Inactive controls render the original four genre rows.
  • Save src/App.jsx.
  • Return to Safari.

Before you test the finished controls, which cards do you expect Lumi to keep when the title search and genre selection change?

  • Select the All genre chip.
  • Enter neon in the search field.

You'll see one Discovery Results card for Neon Harbor. The other eleven cards are removed from the visible catalog.

  • Replace neon with zzz in the search field.

You'll see the No titles found message. This proves the empty-result branch works.

  • Clear the search field.
  • Select the Sci-Fi genre chip.

You'll see three Discovery Results cards. The visible titles are Neon Harbor, Midnight Circuit, and Sunken Sky.

Filtering Not Updating the Cards?

Confirm that the search input uses both value={query} and the setQuery change handler.

Check that the catalog renders filteredMovies while isDiscovering is true.

Help me debug Lumi's live filtering.

✔️ Awesome, I've got everything!

Great work. Save src/App.jsx and keep the Vite development server running.

ⓧ I'd like to double check the full code

import { useState } from 'react'
import { movies } from './movies.js'

const genres = ['All', ...new Set(movies.map((movie) => movie.genre))]

function MovieCard({ movie }) {
  return (
    <article className="movie-card">
      <div className="poster" style={{ background: movie.poster }}>
        <span className="poster-genre">{movie.genre}</span>
        <strong>{movie.title}</strong>
      </div>
      <div className="card-details">
        <div>
          <h3>{movie.title}</h3>
          <p>{movie.year} · {movie.rating}</p>
        </div>
      </div>
    </article>
  )
}

function MovieRow({ title, items }) {
  return (
    <section className="row-section" aria-labelledby={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>
      <div className="section-heading">
        <h2 id={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>{title}</h2>
        <span>{items.length} titles</span>
      </div>
      <div className="movie-row">
        {items.map((movie) => (
          <MovieCard
            key={movie.id}
            movie={movie}
          />
        ))}
      </div>
    </section>
  )
}

export default function App() {
  const [query, setQuery] = useState('')
  const [activeGenre, setActiveGenre] = useState('All')
  const featuredMovie = movies[0]

  const cleanedQuery = query.trim().toLowerCase()
  const filteredMovies = movies.filter((movie) => {
    const matchesText =
      movie.title.toLowerCase().includes(cleanedQuery) ||
      movie.genre.toLowerCase().includes(cleanedQuery)
    const matchesGenre = activeGenre === 'All' || movie.genre === activeGenre
    return matchesText && matchesGenre
  })
  const isDiscovering = cleanedQuery.length > 0 || activeGenre !== 'All'

  return (
    <div className="app-shell">
      <header className="topbar">
        <a className="brand" href="#top" aria-label="Lumi home">
          <span>L</span>
          lumi
        </a>
        <nav aria-label="Primary navigation">
          <a href="#browse">Browse</a>
          <a href="#my-list">My List</a>
        </nav>
      </header>

      <main id="top">
        <section className="hero" style={{ background: featuredMovie.poster }}>
          <div className="hero-shade"></div>
          <div className="hero-content">
            <p className="eyebrow">Lumi featured</p>
            <h1>{featuredMovie.title}</h1>
            <p className="hero-tagline">{featuredMovie.tagline}</p>
            <p className="hero-description">{featuredMovie.description}</p>
            <div className="hero-meta">
              <span>{featuredMovie.year}</span>
              <span>{featuredMovie.rating}</span>
              <span>{featuredMovie.genre}</span>
            </div>
            <div className="hero-actions">
              <a className="primary-button" href="#browse">Browse catalog</a>
              <a className="secondary-button" href="#my-list">Open My List</a>
            </div>
          </div>
        </section>

        <section className="discovery" id="browse" aria-labelledby="discovery-title">
          <div>
            <p className="eyebrow">Explore</p>
            <h2 id="discovery-title">Find your next watch</h2>
          </div>
          <label className="search-box" htmlFor="catalog-search">
            <span>Search titles or genres</span>
            <input
              id="catalog-search"
              type="search"
              value={query}
              placeholder="Try Neon or Sci-Fi"
              onChange={(event) => setQuery(event.target.value)}
            />
          </label>
          <div className="genre-filters" aria-label="Filter by genre">
            {genres.map((genre) => (
              <button
                key={genre}
                type="button"
                className={activeGenre === genre ? 'genre-chip active' : 'genre-chip'}
                aria-pressed={activeGenre === genre}
                onClick={() => setActiveGenre(genre)}
              >
                {genre}
              </button>
            ))}
          </div>
        </section>

        <section className="my-list-panel" id="my-list" aria-labelledby="my-list-title">
          <div className="section-heading">
            <h2 id="my-list-title">My List</h2>
          </div>
          <p className="empty-message">Save a title and it will appear here, even after a refresh.</p>
        </section>

        <section className="catalog" aria-label="Movie catalog">
          {isDiscovering ? (
            filteredMovies.length > 0 ? (
              <MovieRow
                title="Discovery Results"
                items={filteredMovies}
              />
            ) : (
              <div className="no-results">
                <h2>No titles found</h2>
                <p>Try a different title or choose another genre.</p>
              </div>
            )
          ) : (
            genres.slice(1).map((genre) => (
              <MovieRow
                key={genre}
                title={genre}
                items={movies.filter((movie) => movie.genre === genre)}
              />
            ))
          )}
        </section>
      </main>

      <footer>
        <p>Lumi is a fictional learning project with original titles and artwork.</p>
      </footer>
    </div>
  )
}

Your discovery controls now reshape the catalog immediately. Next up, you'll turn the My List placeholder into a saved collection that survives a browser refresh.

Save a Persistent My List

Your search field now narrows the catalog. Your genre chips also reveal the matching movies.

However, React state starts fresh when you reload the page. In this step, browser local storage preserves saved movie IDs across Safari refreshes.

In this step, get ready to:
  • Read saved movie IDs safely when Lumi starts.
  • Connect accessible save controls to immutable state updates.
  • Verify the production build plus persistence after a refresh.
Read saved IDs safely

Browser local storage saves string values for one site across sessions. Lumi stores a JSON array under lumi-my-list.

  • In src/App.jsx, find the genres declaration near the top of the file.
  • Add the storage key plus its safe reader below that declaration using this code:
const storageKey = 'lumi-my-list'

function readSavedIds() {
  try {
    const storedIds = JSON.parse(localStorage.getItem(storageKey) ?? '[]')
    return Array.isArray(storedIds) ? storedIds : []
  } catch {
    return []
  }
}

How does the safe reader work?

  • The storageKey constant gives every read plus write the same key.
  • The JSON.parse() call converts the stored string back into an array.
  • The Array.isArray() check rejects values with the wrong shape.
  • The catch block returns an empty list if the saved value cannot be parsed.
  • Inside App, add the saved-ID state below the activeGenre state using this line:
  const [savedIds, setSavedIds] = useState(readSavedIds)

Why pass the function directly?

Passing readSavedIds gives useState a lazy initializer. React calls it when this component initializes.

This reads storage at startup. The resulting array becomes the first value of savedIds.

  • Save src/App.jsx.
  • Refresh Safari.

You should still see the Lumi hero plus the working discovery controls. This confirms the storage reader compiled without interrupting the app.

Seeing a blank page?

Check that storageKey plus readSavedIds() sit above MovieCard. Confirm that the new useState(readSavedIds) line sits inside App.

Ask for help with the exact error shown by Cursor or Safari: Help me find why readSavedIds is preventing my React app from rendering.

Wire the save controls

A save action needs one source of truth. The handler creates a new ID array before updating React state plus browser storage.

  • In App, replace the existing isDiscovering line with the saved-movie derivation plus toggle logic below:
  const savedMovies = movies.filter((movie) => savedIds.includes(movie.id))
  const isDiscovering = cleanedQuery.length > 0 || activeGenre !== 'All'

  function toggleSaved(movieId) {
    const nextSavedIds = savedIds.includes(movieId)
      ? savedIds.filter((id) => id !== movieId)
      : [...savedIds, movieId]

    setSavedIds(nextSavedIds)
    localStorage.setItem(storageKey, JSON.stringify(nextSavedIds))
  }

What does the toggle logic do?

  • The savedMovies array converts saved IDs into full movie objects for the My List row.
  • The .filter() branch creates a new array without an ID that is already saved.
  • The spread branch creates a new array containing the newly saved ID.
  • The localStorage.setItem() call saves the updated array as a JSON string.
  • Save src/App.jsx.
  • Refresh Safari.

You should see the catalog render with its search plus genre filtering intact. This confirms the new save logic compiles before you connect it to the cards.

Seeing an error after the refresh?

Confirm that savedMovies appears after filteredMovies. Check that toggleSaved() remains inside App.

Use the error text to trace the misplaced line: Help me debug the savedMovies or toggleSaved error in my Lumi App component.

  • Find the MovieRow titled Discovery Results.
  • Replace that component call with this wired version:
              <MovieRow
                title="Discovery Results"
                items={filteredMovies}
                savedIds={savedIds}
                onToggleSaved={toggleSaved}
              />

What reaches the discovery row?

The discovery row receives the current saved-ID array through savedIds. It also receives the save handler through onToggleSaved.

  • Find the MovieRow inside the static genre mapping.
  • Replace that component call with this wired version:
              <MovieRow
                key={genre}
                title={genre}
                items={movies.filter((movie) => movie.genre === genre)}
                savedIds={savedIds}
                onToggleSaved={toggleSaved}
              />

Why wire both catalog paths?

Lumi renders either discovery results or static genre rows. Passing the same props through both paths keeps every movie card connected to one saved list.

  • Save src/App.jsx.
  • Refresh Safari.

You should see the same catalog layout. Searching for neon should still show Neon Harbor.

  • Replace the existing MovieRow component with this version:
function MovieRow({ title, items, savedIds, onToggleSaved }) {
  return (
    <section className="row-section" aria-labelledby={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>
      <div className="section-heading">
        <h2 id={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>{title}</h2>
        <span>{items.length} titles</span>
      </div>
      <div className="movie-row">
        {items.map((movie) => (
          <MovieCard
            key={movie.id}
            movie={movie}
            isSaved={savedIds.includes(movie.id)}
            onToggleSaved={onToggleSaved}
          />
        ))}
      </div>
    </section>
  )
}

How does the row connect each card?

  • The savedIds.includes(movie.id) expression calculates whether the current card is saved.
  • The isSaved prop carries that result into MovieCard.
  • The onToggleSaved prop carries the shared handler into every card.
  • Replace the existing MovieCard component with this accessible version:
function MovieCard({ movie, isSaved, onToggleSaved }) {
  return (
    <article className="movie-card">
      <div className="poster" style={{ background: movie.poster }}>
        <span className="poster-genre">{movie.genre}</span>
        <strong>{movie.title}</strong>
      </div>
      <div className="card-details">
        <div>
          <h3>{movie.title}</h3>
          <p>{movie.year} · {movie.rating}</p>
        </div>
        <button
          className="save-button"
          type="button"
          aria-pressed={isSaved}
          aria-label={`${isSaved ? 'Remove' : 'Add'} ${movie.title} ${isSaved ? 'from' : 'to'} My List`}
          onClick={() => onToggleSaved(movie.id)}
        >
          {isSaved ? 'Saved' : '+ My List'}
        </button>
      </div>
    </article>
  )
}

What makes the save button accessible?

  • The aria-pressed value communicates whether the movie is currently saved.
  • The aria-label value names the movie plus the action available to assistive technology.
  • The visible label switches between + My List plus Saved when state changes.
  • The click handler passes the current movie ID back to toggleSaved().
  • Save src/App.jsx.
  • Type neon into the search field in Safari.
  • Click + My List on the Neon Harbor card.

The button should change to Saved immediately. Your first persistent movie selection now reaches React state plus browser storage.

Button does not change to Saved?

Check that both MovieRow call sites receive savedIds plus toggleSaved. Confirm that MovieRow passes those values to MovieCard.

Trace the complete prop path with help: Why does my Lumi save button render but fail to update its Saved state?

  • Find the My List link inside the top navigation.
  • Replace that link with the live count version below:
          <a href="#my-list">My List <span className="count-badge">{savedIds.length}</span></a>

How does the navigation count stay current?

The badge reads savedIds.length during every render. Saving or removing a movie updates the count immediately.

  • Replace the existing My List panel with this saved-movie view:
        <section className="my-list-panel" id="my-list" aria-labelledby="my-list-title">
          <div className="section-heading">
            <h2 id="my-list-title">My List</h2>
            <span>{savedMovies.length} saved</span>
          </div>
          {savedMovies.length > 0 ? (
            <div className="movie-row">
              {savedMovies.map((movie) => (
                <MovieCard
                  key={movie.id}
                  movie={movie}
                  isSaved={true}
                  onToggleSaved={toggleSaved}
                />
              ))}
            </div>
          ) : (
            <p className="empty-message">Save a title and it will appear here, even after a refresh.</p>
          )}
        </section>

How does My List choose its view?

A non-empty savedMovies array maps each saved movie into a card. Every card receives isSaved={true} because it already belongs to this row.

An empty array displays the guidance message. The panel always gives the viewer a clear next action.

  • Save src/App.jsx.
  • Return to Safari.

You should see Neon Harbor inside My List. The navigation badge plus panel count should both show 1.

Saved card missing from My List?

Confirm that savedMovies filters movies with savedIds.includes(movie.id). Check that the panel maps savedMovies instead of the full catalog.

Ask for a focused comparison: Help me find why savedMovies is not rendering inside my Lumi My List panel.

✔️ Awesome, I've got everything!

Your persistent My List implementation is in place. The next check proves the complete file builds cleanly.

ⓧ I'd like to double check the full code

import { useState } from 'react'
import { movies } from './movies.js'

const genres = ['All', ...new Set(movies.map((movie) => movie.genre))]
const storageKey = 'lumi-my-list'

function readSavedIds() {
  try {
    const storedIds = JSON.parse(localStorage.getItem(storageKey) ?? '[]')
    return Array.isArray(storedIds) ? storedIds : []
  } catch {
    return []
  }
}

function MovieCard({ movie, isSaved, onToggleSaved }) {
  return (
    <article className="movie-card">
      <div className="poster" style={{ background: movie.poster }}>
        <span className="poster-genre">{movie.genre}</span>
        <strong>{movie.title}</strong>
      </div>
      <div className="card-details">
        <div>
          <h3>{movie.title}</h3>
          <p>{movie.year} · {movie.rating}</p>
        </div>
        <button
          className="save-button"
          type="button"
          aria-pressed={isSaved}
          aria-label={`${isSaved ? 'Remove' : 'Add'} ${movie.title} ${isSaved ? 'from' : 'to'} My List`}
          onClick={() => onToggleSaved(movie.id)}
        >
          {isSaved ? 'Saved' : '+ My List'}
        </button>
      </div>
    </article>
  )
}

function MovieRow({ title, items, savedIds, onToggleSaved }) {
  return (
    <section className="row-section" aria-labelledby={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>
      <div className="section-heading">
        <h2 id={`row-${title.replaceAll(' ', '-').toLowerCase()}`}>{title}</h2>
        <span>{items.length} titles</span>
      </div>
      <div className="movie-row">
        {items.map((movie) => (
          <MovieCard
            key={movie.id}
            movie={movie}
            isSaved={savedIds.includes(movie.id)}
            onToggleSaved={onToggleSaved}
          />
        ))}
      </div>
    </section>
  )
}

export default function App() {
  const [query, setQuery] = useState('')
  const [activeGenre, setActiveGenre] = useState('All')
  const [savedIds, setSavedIds] = useState(readSavedIds)
  const featuredMovie = movies[0]

  const cleanedQuery = query.trim().toLowerCase()
  const filteredMovies = movies.filter((movie) => {
    const matchesText =
      movie.title.toLowerCase().includes(cleanedQuery) ||
      movie.genre.toLowerCase().includes(cleanedQuery)
    const matchesGenre = activeGenre === 'All' || movie.genre === activeGenre
    return matchesText && matchesGenre
  })
  const savedMovies = movies.filter((movie) => savedIds.includes(movie.id))
  const isDiscovering = cleanedQuery.length > 0 || activeGenre !== 'All'

  function toggleSaved(movieId) {
    const nextSavedIds = savedIds.includes(movieId)
      ? savedIds.filter((id) => id !== movieId)
      : [...savedIds, movieId]

    setSavedIds(nextSavedIds)
    localStorage.setItem(storageKey, JSON.stringify(nextSavedIds))
  }

  return (
    <div className="app-shell">
      <header className="topbar">
        <a className="brand" href="#top" aria-label="Lumi home">
          <span>L</span>
          lumi
        </a>
        <nav aria-label="Primary navigation">
          <a href="#browse">Browse</a>
          <a href="#my-list">My List <span className="count-badge">{savedIds.length}</span></a>
        </nav>
      </header>

      <main id="top">
        <section className="hero" style={{ background: featuredMovie.poster }}>
          <div className="hero-shade"></div>
          <div className="hero-content">
            <p className="eyebrow">Lumi featured</p>
            <h1>{featuredMovie.title}</h1>
            <p className="hero-tagline">{featuredMovie.tagline}</p>
            <p className="hero-description">{featuredMovie.description}</p>
            <div className="hero-meta">
              <span>{featuredMovie.year}</span>
              <span>{featuredMovie.rating}</span>
              <span>{featuredMovie.genre}</span>
            </div>
            <div className="hero-actions">
              <a className="primary-button" href="#browse">Browse catalog</a>
              <a className="secondary-button" href="#my-list">Open My List</a>
            </div>
          </div>
        </section>

        <section className="discovery" id="browse" aria-labelledby="discovery-title">
          <div>
            <p className="eyebrow">Explore</p>
            <h2 id="discovery-title">Find your next watch</h2>
          </div>
          <label className="search-box" htmlFor="catalog-search">
            <span>Search titles or genres</span>
            <input
              id="catalog-search"
              type="search"
              value={query}
              placeholder="Try Neon or Sci-Fi"
              onChange={(event) => setQuery(event.target.value)}
            />
          </label>
          <div className="genre-filters" aria-label="Filter by genre">
            {genres.map((genre) => (
              <button
                key={genre}
                type="button"
                className={activeGenre === genre ? 'genre-chip active' : 'genre-chip'}
                aria-pressed={activeGenre === genre}
                onClick={() => setActiveGenre(genre)}
              >
                {genre}
              </button>
            ))}
          </div>
        </section>

        <section className="my-list-panel" id="my-list" aria-labelledby="my-list-title">
          <div className="section-heading">
            <h2 id="my-list-title">My List</h2>
            <span>{savedMovies.length} saved</span>
          </div>
          {savedMovies.length > 0 ? (
            <div className="movie-row">
              {savedMovies.map((movie) => (
                <MovieCard
                  key={movie.id}
                  movie={movie}
                  isSaved={true}
                  onToggleSaved={toggleSaved}
                />
              ))}
            </div>
          ) : (
            <p className="empty-message">Save a title and it will appear here, even after a refresh.</p>
          )}
        </section>

        <section className="catalog" aria-label="Movie catalog">
          {isDiscovering ? (
            filteredMovies.length > 0 ? (
              <MovieRow
                title="Discovery Results"
                items={filteredMovies}
                savedIds={savedIds}
                onToggleSaved={toggleSaved}
              />
            ) : (
              <div className="no-results">
                <h2>No titles found</h2>
                <p>Try a different title or choose another genre.</p>
              </div>
            )
          ) : (
            genres.slice(1).map((genre) => (
              <MovieRow
                key={genre}
                title={genre}
                items={movies.filter((movie) => movie.genre === genre)}
                savedIds={savedIds}
                onToggleSaved={toggleSaved}
              />
            ))
          )}
        </section>
      </main>

      <footer>
        <p>Lumi is a fictional learning project with original titles and artwork.</p>
      </footer>
    </div>
  )
}
Prove the persistent list works

A browser test proves persistence. A production build checks that the complete app can be compiled for release.

  • Press Cmd+I in Cursor to open Agent.
  • Ask Agent to review the current diff for accidental array mutations.
  • Ask Agent to identify unused code.
  • Review Cursor's diff to confirm that only the intended My List implementation changed.

Before you run the production check, do you expect the new storage plus component wiring to compile successfully?

  • Verify the production build by running this command in another Cursor terminal:
npm run build

What does the build check prove?

The Vite production build processes the complete component tree. A successful finish confirms that the new props plus storage logic contain no compilation errors.

Production build failed?

Use the first reported file plus line number as your starting point. Compare that section of src/App.jsx with the full-code tab above.

Share the build error without any private data: Help me diagnose this Lumi production build failure after adding persistent My List state.

Before you refresh Safari, do you expect Neon Harbor to remain saved after React initializes again?

  • Return to the Lumi site served by the development server from earlier.
  • Refresh Safari.
  • Confirm that the Neon Harbor card still says Saved.
  • Confirm that Neon Harbor remains in the My List row.
  • Confirm that the navigation badge still shows 1.

That is the complete persistence loop. Lumi now restores the saved movie ID from lumi-my-list whenever the app starts.

Secret mission

Add a Surprise Me Hero

Lumi currently features the same movie every time someone visits. Add a Surprise Me button that selects a different fictional title while preserving search and My List behavior.

Clean Up Your Resources

Clean Up Your Resources

Everything in Lumi runs on your Mac, so the project creates no cloud charges. Choose whether to keep the project available, pause its development server, or delete its local resources.

Resources you used:

  • The local lumi-streaming folder with your React source files.
  • The node_modules folder with the installed Vite packages.
  • The generated package-lock.json dependency lockfile.
  • The optional dist production-build folder.
  • The saved My List data stored by Safari for http://localhost:5173.

Keep everything running

No action is needed. Choose this option if you want to continue improving Lumi or demonstrate it again later.

  • Leave the lumi-streaming folder in its current location.
  • Keep Safari's local site data so your saved My List remains available.
  • When you return, switch back to Cursor.
  • Restart Lumi by running this command:
npm run dev

What does this command do?

The dev script in package.json starts the local Vite server. Safari can then load Lumi at http://localhost:5173.

The terminal remains attached to the server while Lumi is running.

Pause - I'll come back to this later

Pause the Vite development server to free the terminal while preserving the project. Safari keeps the saved My List for your next session.

  • Switch back to the running terminal in Cursor.
  • Press Control+C to stop the development server.

That completes the pause. Your lumi-streaming folder remains ready for your next session.

Delete - I don't want to use this again

This option permanently removes Lumi's local files after Trash is emptied. Safari's saved My List needs its own cleanup.

Deletion is permanent

Emptying Trash removes the only local copy of your Lumi source code.

  • Switch back to Cursor's running terminal.
  • Press Control+C to stop the Vite development server.
  • Use Finder to move the lumi-streaming folder to Trash.
  • Check that lumi-streaming is the folder you intend to remove.
  • Empty Trash to delete the source files and generated project folders.
  • In Safari, open the website-data settings.
  • Search the stored website data for localhost.
  • Remove the matching entry to clear Lumi's saved My List.

The local Lumi project is now removed. Safari no longer keeps its saved My List.

Nice Work!

Nice Work!

You did it! You built Lumi as an original streaming catalog with React.

The responsive interface now supports live discovery, a persistent My List, and a hero that changes on demand.

You've learned how to:

  • Built reusable React components for the hero, movie cards, and scrolling rows. Applied responsive CSS so the catalog works at desktop and mobile widths.
  • Used Cursor Agent with Plan Mode to plan the interface. Reviewed each diff before accepting changes.
  • Connected controlled inputs to React state so title searches and genre filters update the visible catalog. Stored saved movie IDs with localStorage so My List survives browser refreshes.
  • Secret Mission: Added a Surprise Me hero that selects a different fictional title on every click. Confirmed that search and My List still work after the extension.

Ready to quiz yourself?