Build a Next.js Dock App

Package a React Bits workspace that launches from your Dock into Firefox.

Introduction

30 Second Summary

A web app feels easier to return to when it launches like every other Mac app. A Dock icon removes the terminal ritual between you and your workspace.

In this project, you will build a Next.js chat workspace and package it as an Intel Electron app for macOS Ventura. Clicking its Dock icon starts the local service and opens the interface in Firefox, while React Bits supplies the animated visual layer.

What You'll Build

Click your finished app in the Dock to watch Firefox open a glowing chat workspace with its saved conversation ready to continue.

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

  • A Firefox-based chat workspace with an Aurora background, animated greeting, and pointer-lit cards from React Bits.
  • A local conversation archive that restores complete messages and compact recaps after you quit the app.
  • An Intel macOS application with its own icon that you can keep in Applications and pin to the Dock.
  • Secret Mission: Add Dock-menu actions that reopen Firefox and reveal the local archive in Finder.

Are there any prerequisites?

You'll need your Intel Mac running macOS Ventura, Node.js 20.9 or newer, and Firefox set as the default browser.

No cloud account, API key, Automator workflow, or paid service is required.

Before We Start

Before you start, lock in the experience you are building. Your finished Dock app starts a local service, opens Firefox, and restores a chat workspace whose data stays on your Mac.

Set Up the Local Workspace

Set Up Next.js and Electron

Your Dock launcher depends on a predictable web toolchain before Electron enters the picture. A version mismatch here would look like an app-packaging failure later.

This step creates a clean Next.js App Router project on your Desktop. It also installs the Intel-compatible Electron packaging tools and proves the starter page runs in Firefox.

In this step, get ready to:
  • Confirm that Node.js meets the Next.js requirement.
  • Create the Next.js workspace and install Electron.
  • Run the starter page in Firefox.
Check the local runtime

Next.js 16 requires Node.js 20.9 or newer. Electron 44 supports macOS Ventura and publishes an Intel x64 build, which matches your confirmed Mac.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
  • Type Terminal and press Enter.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 20 or higher

Your runtime is ready when the major version is 20 or higher. Keep Terminal open for the project commands.

ⓧ I see an older version

Your installed runtime is below the Next.js minimum. Upgrade it before creating the workspace.

  • Open the official Node.js download page.
  • Download the current LTS installer for macOS.
  • Complete the installer with its default options.
  • Open a fresh Terminal window after installation.
  • Run the version check again:
node --version

ⓧ Command not found

Terminal cannot find Node.js yet. Install the current LTS release before continuing.

  • Open the official Node.js download page.
  • Download the current LTS installer for macOS.
  • Complete the installer with its default options.
  • Open a fresh Terminal window after installation.
  • Confirm the command now works:
node --version

Still seeing the wrong version?

Close and reopen Terminal so it reloads the command path. If the old result remains, run help me identify the active Node.js installation.

Create the Next.js workspace

The scaffold command creates the App Router, TypeScript, Tailwind CSS, and ESLint configuration in one pass. The first install can take two or three minutes while npm downloads the packages.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the local-context-workspace project by running this command:
npx create-next-app@latest local-context-workspace --ts --tailwind --eslint --app --use-npm --import-alias "@/*"

What should the scaffold ask?

The flags answer the setup choices, so the command should not open the configuration wizard. npm may ask permission to install create-next-app. Enter y if that confirmation appears.

  • Move into the new project by running this command:
cd local-context-workspace
  • Install Electron 44 and electron-builder by running:
npm install --save-dev electron@44.7.2 electron-builder@26.8.1
  • Confirm both development packages are installed by running:
npm list electron electron-builder

You should see electron@44.7.2 and electron-builder@26.8.1 in the dependency tree.

Electron installation failing?

Check that Terminal is inside ~/Desktop/local-context-workspace before installing. If npm reports a network or permission failure, use help me diagnose this Electron installation.

Run the starter in Firefox

The development server gives you the first visible proof before any desktop packaging begins. It occupies this Terminal window until you stop it with Control+C.

Before you run it, what do you expect Firefox to show at the local address?

  • Start the Next.js development server by running:
npm run dev
  • Open Firefox through system search.
  • Enter http://localhost:3000 in the address bar.

You should see the Next.js starter page. That first browser result proves the scaffold and local runtime work together.

Firefox cannot reach the page?

Confirm that Terminal still shows the running development server. If Firefox cannot connect, use help me connect Firefox to the Next.js server.

Your clean Next.js foundation is running in Firefox. Next, you will replace the starter with a React Bits workspace that earns its place in the Dock.

Build the In-Memory Chat Shell

Build the React Bits Workspace

Your Next.js starter now runs in Firefox. The next goal is a chat surface whose movement and depth make the Dock launch feel worthwhile.

This step installs three owned-source React Bits components: Aurora, Split Text, and Spotlight Card. You will combine them with a small in-memory conversation before persistence moves into Electron.

In this step, get ready to:
  • Install Aurora, Split Text, and Spotlight Card from React Bits.
  • Build the animated chat workspace as client components.
  • Send a message and inspect the result in Firefox.
Install the React Bits components

React Bits installs each component as source code inside your project. That gives you the visual effect without turning the page into a dependency on a remote widget.

Why these three effects?

Aurora supplies a WebGL background that drifts toward the pointer. Split Text animates the opening heading, while Spotlight Card lets each suggestion react to nearby pointer movement.

This trio gives the page one background effect, one text entrance, and one interaction pattern. The result stays coherent instead of becoming a wall of unrelated animation.

  • Stop the running Next.js server by pressing Control+C in Terminal.
  • Install Aurora by running this command:
npx shadcn@latest add @react-bits/Aurora-TS-TW
  • Install Split Text by running this command:
npx shadcn@latest add @react-bits/SplitText-TS-TW
  • Install Spotlight Card by running this command:
npx shadcn@latest add @react-bits/SpotlightCard-TS-TW

Seeing setup questions?

The first command may ask to create a components.json file or choose a base colour. Accept the recommended defaults, then allow each command to add its component and required packages.

  • Confirm the component files exist by running:
ls components

You should see files for Aurora, SplitText, and SpotlightCard in the output.

React Bits installation failing?

Read the destination path printed by the command if a filename differs in capitalization. If the registry command fails, use help me repair the React Bits installation.

Build the interactive workspace

The workspace keeps its first messages in React state. This gives you a fast visual loop before the next step replaces temporary memory with Electron-backed files.

  • Create components/PromptCards.tsx inside the local-context-workspace folder.
  • Add the suggestion cards by pasting this complete file:
import SpotlightCard from '@/components/SpotlightCard';

const prompts = ['Plan a local-first feature', 'Review a design decision', 'Summarize our current goal'];

type Props = { onPick: (prompt: string) => void };

export default function PromptCards({ onPick }: Props) {
  // Each card sends its prompt back to the composer.
  return (
    <div className="grid gap-3 md:grid-cols-3">
      {prompts.map(prompt => (
        <SpotlightCard className="rounded-2xl border border-white/10 bg-white/5 p-2" key={prompt} spotlightColor="#a78bfa">
          <button className="min-h-24 w-full p-4 text-left text-sm text-zinc-200" onClick={() => onPick(prompt)} type="button">{prompt}</button>
        </SpotlightCard>
      ))}
    </div>
  );
}

What does this component do?

  • The prompt array keeps the three starting ideas in one place.
  • Each Spotlight Card wraps a real button so keyboard and pointer input both work.
  • The onPick callback moves the selected idea into the composer.
  • Save components/PromptCards.tsx.
  • Confirm TypeScript can parse the component by running:
npx tsc --noEmit

The command should finish without a TypeScript error. That confirms the card component can join the page.

Spotlight Card import missing?

Check the generated Spotlight Card filename if the import cannot resolve. Use help me fix the Spotlight Card import.

  • Create components/ChatWorkspace.tsx inside the same components folder.
  • Add the animated workspace by pasting this complete file:
'use client';
import { useState } from 'react';
import Aurora from '@/components/Aurora';
import SplitText from '@/components/SplitText';
import PromptCards from '@/components/PromptCards';

export default function ChatWorkspace() {
  // Draft holds the composer text. Messages holds this temporary session.
  const [draft, setDraft] = useState('');
  const [messages, setMessages] = useState<string[]>([]);
  function sendMessage() {
    const message = draft.trim();
    if (!message) return;
    setMessages(current => [...current, message, `Local preview received: ${message}`]);
    setDraft('');
  }
  return (
    <main className="relative min-h-screen overflow-hidden bg-[#07070b] text-white">
      <Aurora className="absolute inset-x-0 top-0 h-[55vh]" colorStops={['#7c3aed', '#22d3ee', '#f472b6']} height={0.55} layers={2} />
      <section className="relative mx-auto flex min-h-screen max-w-5xl flex-col px-6 py-10">
        <header className="mb-auto flex items-center justify-between"><strong>Local Context</strong><span className="rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300">Firefox preview</span></header>
        <SplitText className="mt-16 text-5xl font-semibold tracking-tight md:text-7xl" delay={35} duration={0.9} tag="h1" text="What are we building today?" />
        <p className="my-6 max-w-2xl text-zinc-300">A local workspace that launches from your Dock and remembers the conversation.</p>
        <PromptCards onPick={setDraft} />
        <div className="my-8 space-y-3">{messages.map((message, index) => <article className="rounded-2xl border border-white/10 bg-black/30 p-4" key={`${message}-${index}`}>{message}</article>)}</div>
        <form className="mt-auto flex gap-3 rounded-2xl border border-white/10 bg-black/50 p-3" onSubmit={event => { event.preventDefault(); sendMessage(); }}><textarea className="min-h-14 flex-1 resize-none bg-transparent p-3 outline-none" onChange={event => setDraft(event.target.value)} placeholder="Message your local workspace" value={draft} /><button className="rounded-xl bg-white px-5 font-medium text-black" type="submit">Send</button></form>
      </section>
    </main>
  );
}

How does the workspace fit together?

  • Aurora occupies the upper half of the page without blocking the interface.
  • Split Text animates the main question once the client component mounts.
  • The composer appends a user message plus a deterministic preview response to React state.
  • Save components/ChatWorkspace.tsx.
  • Confirm the complete client component passes TypeScript by running:
npx tsc --noEmit

A quiet exit confirms all three React Bits imports and the workspace props are valid.

Workspace type check failing?

Match each import to the generated component filename if TypeScript reports a missing module. If JSX reports a line near the form, use help me debug the workspace component.

Connect the page and test the visual result
  • Replace the contents of app/page.tsx with this page component:
import ChatWorkspace from '@/components/ChatWorkspace';

export default function Home() {
  // The route delegates its interactive interface to a client component.
  return <ChatWorkspace />;
}

Why keep the page this small?

The App Router page remains a Server Component. It hands browser state and animation work to ChatWorkspace.

  • Save app/page.tsx.
  • Start the development server by running:
npm run dev

Before you return to Firefox, which effect do you expect to notice first: the moving colour, the heading entrance, or the cards?

  • Return to the Firefox tab at http://localhost:3000.
  • Refresh the page.
  • Move the pointer across the suggestion cards.
  • Select one suggestion.
  • Click Send.

You should see a flowing aurora, an animated heading, pointer-lit suggestion cards, and two new message cards. That is the first complete version of your spectacular Firefox interface.

Seeing a blank page?

Open Firefox Developer Tools and check the first console error if the page is blank. Use help me diagnose the blank React Bits page.

Your Firefox workspace now looks and behaves like the finished product. Next, Electron will become its launcher, local server, and durable storage boundary.

Persist the Full Transcript

Launch Firefox and Save the Transcript

Your React Bits workspace now gives Firefox a polished front end. The missing piece is a Mac process that can serve that page, save its transcript, and open the default browser without a terminal ritual.

Electron becomes that local process. It runs a loopback HTTP server, stores conversation data under the app's own support directory, and hands the local URL to Firefox through the operating system.

In this step, get ready to:
  • Export the Next.js interface as static files.
  • Build the Electron launcher and local transcript service.
  • Send a message through Firefox and restore it after a refresh.
Prepare a static Next.js build

Electron needs files it can serve without running the Next.js development process. The static export writes the rendered route and its assets into out.

  • Stop the development server by pressing Control+C in Terminal.
  • Replace next.config.ts inside local-context-workspace with this configuration:
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // Export browser-ready files for Electron's local server.
  output: 'export'
};

export default nextConfig;

What does this configuration change?

  • output: 'export' tells Next.js to write a static site during the production build.
  • The exported site can still call /api/messages because Electron serves the page and API from the same local origin.
  • Save next.config.ts.
  • Build the static site by running:
npm run build
  • Confirm the export exists by running:
ls out

You should see index.html and a _next directory. Those are the files Electron serves.

Static export failing?

Check that the option is inside the exported nextConfig object. If the build reports a React Bits browser API error, use help me diagnose the static export.

Build the Electron service

The service has three boundaries: storage owns the JSON transcript, the server owns HTTP, and the Electron main process owns macOS integration. Keeping them separate makes each failure easier to locate.

  • Create an electron folder inside local-context-workspace.
  • Create electron/storage.mjs.
  • Add the transcript store by pasting this complete file:
import * as fs from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';

export class TranscriptStore {
  constructor(root) {
    // The transcript stays inside Electron's per-user data directory.
    this.root = root;
    this.file = path.join(root, 'transcript.json');
  }
  async read() {
    try { return JSON.parse(await fs.readFile(this.file, 'utf8')); }
    catch (error) {
      if (error?.code === 'ENOENT') return [];
      throw error;
    }
  }
  async append(text) {
    const messages = await this.read();
    const createdAt = new Date().toISOString();
    messages.push({ id: randomUUID(), role: 'user', content: text, createdAt });
    messages.push({ id: randomUUID(), role: 'assistant', content: `Local reply: I saved "${text}" on this Mac.`, createdAt });
    await fs.mkdir(this.root, { recursive: true });
    await fs.writeFile(this.file, JSON.stringify(messages, null, 2), 'utf8');
    return messages;
  }
}

How does the transcript store work?

  • read() returns an empty conversation when the transcript does not exist yet.
  • append() writes the user message and deterministic local reply before returning the complete history.
  • randomUUID() gives every message a stable key for React.
  • Save electron/storage.mjs.
  • Check its syntax by running:
node --check electron/storage.mjs

The command should finish without output. A quiet exit confirms the module parses.

Storage module has a syntax error?

Check the braces around the class methods if Node prints a line number. Use help me repair the transcript store.

  • Create electron/server.mjs.
  • Add the server helpers by pasting this first section:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { TranscriptStore } from './storage.mjs';

const types = { '.css': 'text/css', '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };

// Keep JSON responses and request parsing consistent.
function sendJson(response, status, value) {
  response.writeHead(status, { 'Content-Type': 'application/json' });
  response.end(JSON.stringify(value));
}

async function readBody(request) {
  let body = '';
  for await (const chunk of request) body += chunk;
  return JSON.parse(body || '{}');
}

What do these helpers provide?

  • The MIME map lets Firefox interpret exported scripts, styles, images, and fonts correctly.
  • sendJson() and readBody() keep the two API routes small.
  • Add the exported server function below readBody().
export function startServer({ webRoot, dataRoot }) {
  const store = new TranscriptStore(dataRoot);
  return new Promise((resolve, reject) => {
    // One loopback server owns both the app files and transcript API.
    const server = createServer(async (request, response) => {
      try {
        const url = new URL(request.url ?? '/', 'http://127.0.0.1');
        if (request.method === 'GET' && url.pathname === '/api/messages') return sendJson(response, 200, { messages: await store.read() });
        if (request.method === 'POST' && url.pathname === '/api/messages') {
          const text = String((await readBody(request)).message ?? '').trim();
          if (!text) return sendJson(response, 400, { error: 'A message is required.' });
          return sendJson(response, 200, { messages: await store.append(text) });
        }
        const relative = url.pathname === '/' ? 'index.html' : decodeURIComponent(url.pathname.slice(1));
        const filePath = path.join(webRoot, relative);
        if (!filePath.startsWith(webRoot)) throw new Error('Forbidden path.');
        const file = await readFile(filePath);
        response.writeHead(200, { 'Content-Type': types[path.extname(filePath)] ?? 'application/octet-stream' });
        response.end(file);
      } catch (error) { sendJson(response, 500, { error: error.message }); }
    });
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`));
  });
}

How does the local server stay self-contained?

  • Port 0 asks macOS for an available local port each time the app starts.
  • The browser and API share one origin, so the React client can use relative fetch requests.
  • The server binds only to 127.0.0.1, which keeps it on your Mac.
  • Save electron/server.mjs.
  • Check the assembled server by running:
node --check electron/server.mjs

A quiet exit confirms both server sections form one valid module.

Server module has a syntax error?

Confirm that startServer() sits below the helper functions in the same file. Use help me repair the local server.

  • Create electron/main.mjs.
  • Add the macOS launcher by pasting this complete file:
import { app, shell } from 'electron';
import path from 'node:path';
import { startServer } from './server.mjs';

let workspaceUrl = '';

async function openWorkspace() {
  if (workspaceUrl) await shell.openExternal(workspaceUrl);
}

app.whenReady().then(async () => {
  // Development serves out/. Packaging serves the copied web resource.
  const webRoot = app.isPackaged ? path.join(process.resourcesPath, 'web') : path.join(app.getAppPath(), 'out');
  const dataRoot = path.join(app.getPath('userData'), 'workspace');
  workspaceUrl = await startServer({ webRoot, dataRoot });
  await openWorkspace();
});

app.on('activate', openWorkspace);

Why does this open Firefox?

shell.openExternal() asks macOS to open the local URL with the current default handler. Because Firefox is already your default browser, the workspace opens there without hard-coding an application path.

  • Save electron/main.mjs.
  • Check the main process by running:
node --check electron/main.mjs

A quiet exit confirms the launcher parses before Electron runs it.

Main process not parsing?

Check that the Electron import uses app and shell as named exports. Use help me repair the Electron launcher.

Connect the client and launch Firefox

The browser interface now needs one reusable hook for loading and sending persisted messages. The visual React Bits components remain unchanged.

  • Create components/useTranscript.ts.
  • Add the browser-side persistence hook by pasting this complete file:
'use client';
import { useEffect, useState } from 'react';

export type Message = { id: string; role: 'user' | 'assistant'; content: string; createdAt: string };

export function useTranscript() {
  // These values bridge the composer to Electron's local API.
  const [messages, setMessages] = useState<Message[]>([]);
  const [draft, setDraft] = useState('');
  const [status, setStatus] = useState('Loading local transcript');
  useEffect(() => {
    fetch('/api/messages').then(response => response.json()).then(data => {
      setMessages(data.messages);
      setStatus('Saved locally');
    }).catch(() => setStatus('Local service unavailable'));
  }, []);
  async function sendMessage() {
    const message = draft.trim();
    if (!message) return;
    setStatus('Saving locally');
    const response = await fetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) });
    const data = await response.json();
    if (!response.ok) throw new Error(data.error ?? 'Local request failed.');
    setMessages(data.messages);
    setDraft('');
    setStatus('Saved locally');
  }
  return { messages, draft, setDraft, sendMessage, status };
}

What does the hook own?

  • The loading Effect restores the transcript once when Firefox opens the page.
  • sendMessage() waits for Electron to save the turn before replacing the rendered history.
  • The status text exposes the current storage state without changing the animation layer.
  • Open components/ChatWorkspace.tsx.
  • Replace the useState import with this hook import:
import { useTranscript } from '@/components/useTranscript';
  • Replace both state declarations and the existing sendMessage() function with this line:
  // Electron now owns the durable transcript behind this hook.
  const { messages, draft, setDraft, sendMessage, status } = useTranscript();
  • Replace the message-list expression with this version:
        <div className="my-8 space-y-3">{messages.map(message => <article className="rounded-2xl border border-white/10 bg-black/30 p-4" key={message.id}><strong className="mb-2 block text-xs uppercase tracking-widest text-violet-300">{message.role}</strong>{message.content}</article>)}</div>
  • Replace Firefox preview in the header badge with the live {status} expression.
  • Save components/ChatWorkspace.tsx.
  • Run the TypeScript check:
npx tsc --noEmit

The check should finish without an error. That confirms the visual workspace now consumes persisted message objects.

Seeing duplicate state errors?

Remove the old useState import and temporary sendMessage() function if TypeScript reports duplicate names. Use help me connect the transcript hook.

Electron reads the entry file from package.json. Add that field and a launch script with two surgical edits.

  • Open package.json.
  • Add this field after the version field:
  "main": "electron/main.mjs",
  • Add this entry inside the existing scripts object:
    "electron": "npm run build && electron .",
  • Save package.json.
  • Check the JSON syntax by running:
npm pkg get main scripts.electron

You should see electron/main.mjs and the build-then-launch script in the output.

package.json no longer parses?

JSON does not allow a trailing comma on the last property. Place each new entry before another property if you want to keep its comma, then use help me repair package.json.

Before you launch Electron, do you expect a desktop window or a Firefox tab to appear?

  • Build the export and start Electron by running:
npm run electron

The build takes about a minute. Electron then starts the local service and macOS opens the URL in Firefox because Firefox is your default browser.

  • Enter Remember this Firefox launch. in the composer.
  • Click Send.
  • Refresh the Firefox tab.

You should see the user message and local reply return after the refresh. The Saved locally badge confirms Firefox reloaded them from Electron's transcript service.

Firefox opens but the workspace fails?

Keep the Electron terminal running during this check. If Firefox opens but the page fails, use help me trace the Electron-to-Firefox request.

Electron now launches the workspace in Firefox and owns its durable transcript. Next, you will add compact recap files so the archive shows what each session accomplished.

Archive Recaps and Restore Context

Archive Recaps Across Restarts

Electron now restores complete messages whenever Firefox refreshes. A growing transcript remains useful as a record, but it is too detailed to serve as the quickest summary of where a session left off.

This step creates one compact recap after every turn. The next Electron process reads the newest recap before answering, which proves continuity survives beyond process memory.

In this step, get ready to:
  • Add immutable recap files to the transcript store.
  • Expose the newest recap through the local API.
  • Restart Electron and prove the recap still influences the reply.
Add the recap archive

Each recap is a separate text file containing the message count and latest user request. A timestamped filename preserves earlier recaps instead of replacing them.

  • Stop Electron by pressing Control+C in its Terminal window.
  • Open electron/storage.mjs.
  • Find these constructor lines:
    this.root = root;
    this.file = path.join(root, 'transcript.json');
  • Replace them with this version:
    this.root = root;
    this.file = path.join(root, 'transcript.json');
    // Every compact session summary receives its own file.
    this.recaps = path.join(root, 'recaps');

The new path keeps the transcript and recap archive under the same Electron-managed workspace directory.

  • Insert these two methods between read() and append().
  async latestRecap() {
    try {
      const files = (await fs.readdir(this.recaps)).filter(name => name.endsWith('.txt')).sort();
      const latest = files.at(-1);
      return latest ? fs.readFile(path.join(this.recaps, latest), 'utf8') : '';
    } catch (error) {
      if (error?.code === 'ENOENT') return '';
      throw error;
    }
  }

  async writeRecap(messages) {
    // Recaps stay compact while the JSON transcript keeps every message.
    const latestRequest = [...messages].reverse().find(message => message.role === 'user');
    const recap = `LOCAL CONTEXT RECAP\nSaved messages: ${messages.length}\nLatest request: ${latestRequest?.content ?? 'None'}`;
    const name = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.txt`;
    await fs.mkdir(this.recaps, { recursive: true });
    await fs.writeFile(path.join(this.recaps, name), recap, { encoding: 'utf8', flag: 'wx' });
  }

How does the archive stay compact?

  • latestRecap() selects the newest timestamped file or returns an empty string on the first turn.
  • writeRecap() records only the message count and latest request.
  • flag: 'wx' refuses to overwrite an existing recap file.
  • Find the complete append() method.
  • Replace it with this recap-aware version:
  async append(text) {
    const messages = await this.read();
    const previousRecap = await this.latestRecap();
    const createdAt = new Date().toISOString();
    messages.push({ id: randomUUID(), role: 'user', content: text, createdAt });
    const continuity = previousRecap ? 'I restored the newest local recap.' : 'This is the first recap-aware turn.';
    messages.push({ id: randomUUID(), role: 'assistant', content: `${continuity} I saved "${text}" on this Mac.`, createdAt });
    await fs.mkdir(this.root, { recursive: true });
    await fs.writeFile(this.file, JSON.stringify(messages, null, 2), 'utf8');
    await this.writeRecap(messages);
    return messages;
  }

How does the reply prove continuity?

The method reads the previous recap before it writes the new one. The deterministic reply therefore reveals whether compact context existed when the turn began.

  • Save electron/storage.mjs.
  • Check the updated module by running:
node --check electron/storage.mjs

The command should exit quietly. That confirms the constructor, recap methods, and updated append method share one valid class body.

Recap methods not parsing?

Check that both new methods sit inside TranscriptStore and outside one another. Use help me repair the recap methods.

Expose the newest recap

A read-only API route gives you a safe way to inspect the newest recap without guessing the current Electron data path. Firefox displays the plain JSON response on the same local server.

  • Open electron/server.mjs.
  • Find the existing message-loading route:
        if (request.method === 'GET' && url.pathname === '/api/messages') return sendJson(response, 200, { messages: await store.read() });
  • Add this route immediately below it:
        if (request.method === 'GET' && url.pathname === '/api/recap') return sendJson(response, 200, { recap: await store.latestRecap() });

The route returns only the newest compact recap. It does not alter the transcript or create another file.

  • Save electron/server.mjs.
  • Check the server module by running:
node --check electron/server.mjs

A quiet exit confirms the recap route sits inside the request handler.

Recap endpoint not responding?

Place the new route before the static-file fallback. If it appears after the file read, Firefox may receive an error page instead of recap JSON. Use help me trace the recap endpoint.

Restart and prove continuity

The first turn after this change creates the archive. The restart removes every in-memory value, so the following reply can report restored context only by reading the recap file.

  • Build and start Electron by running:
npm run electron
  • Wait for Firefox to open the workspace.
  • Enter Plan the Dock release. in the composer.
  • Click Send.

You should see the first recap-aware response. It reports that this is the first turn because the archive did not exist when the request began.

  • Copy the local base URL from the Firefox address bar.
  • Open a new Firefox tab.
  • Append /api/recap to the copied base URL.

Firefox should show JSON containing LOCAL CONTEXT RECAP, the saved message count, and Plan the Dock release. as the latest request.

Before you restart, do you think the next reply can still detect that recap after Electron loses its process memory?

  • Stop Electron by pressing Control+C.
  • Start a new Electron process by running:
npm run electron
  • Wait for the new Firefox tab to open.
  • Enter Continue from the saved plan. in the composer.
  • Click Send.

You should see every earlier message plus a new reply beginning with I restored the newest local recap. That sentence proves continuity came from disk after the restart.

Context still looks new after restart?

Confirm that you sent one recap-aware message before the restart. If the reply still reports a first turn, use help me trace recap continuity.

Your archive now carries both detailed history and compact continuity across Electron restarts. Next, you will package that system as an Intel Mac app with its own Dock icon.

Prove the Restart Flow

Package the Intel Dock App

Your Next.js interface, transcript, and recap archive now survive complete Electron restarts. The final goal is to turn that working development process into an Intel application you can keep in Applications and launch from the Dock.

electron-builder packages the main process, copies the static web export, and converts your source artwork into a macOS icon. The resulting app still opens Firefox because the packaged launcher uses the same default-browser handoff.

In this step, get ready to:
  • Create custom source artwork for the app icon.
  • Build an Intel DMG with the static Next.js export.
  • Install the app and launch Firefox from its Dock icon.
Create the app icon

A Dock app needs artwork that remains clear at both small and large sizes. This icon echoes the workspace's violet, cyan, and pink Aurora palette without copying the page itself.

  • Create the build folder by running:
mkdir -p build
  • Create build/icon.svg inside the local-context-workspace folder.
  • Add the icon artwork by pasting this complete file:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
  <defs>
    <!-- The gradient mirrors the Aurora colours in the workspace. -->
    <linearGradient id="aurora" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#7c3aed" />
      <stop offset="0.52" stop-color="#22d3ee" />
      <stop offset="1" stop-color="#f472b6" />
    </linearGradient>
  </defs>
  <rect width="1024" height="1024" rx="224" fill="#07070b" />
  <circle cx="512" cy="420" r="300" fill="url(#aurora)" opacity="0.92" />
  <path d="M272 324c0-66 54-120 120-120h240c66 0 120 54 120 120v232c0 66-54 120-120 120H492L338 790v-114c-39-19-66-59-66-106V324Z" fill="#0b0b12" stroke="#fff" stroke-opacity="0.72" stroke-width="34" />
  <circle cx="408" cy="442" r="34" fill="#fff" />
  <circle cx="512" cy="442" r="34" fill="#fff" />
  <circle cx="616" cy="442" r="34" fill="#fff" />
</svg>

What makes the icon readable?

  • The rounded dark tile stays legible against Ventura's Dock.
  • The gradient circle links the package to the React Bits Aurora background.
  • The white chat marks remain visible when macOS renders the icon at a small size.
  • Save build/icon.svg.
  • Confirm the artwork exists by running:
ls build/icon.svg

You should see build/icon.svg printed in the terminal.

Icon file not detected?

Check that the file starts with <svg and ends with </svg> if the builder cannot read it. Use help me validate the app icon.

Configure the Intel package

Your Ventura Mac uses Intel hardware, so the package targets x64 only. The static out directory travels as an extra resource because the Electron main process serves it at runtime.

  • Open package.json.
  • Add this launch script inside the existing scripts object:
    "dist": "npm run build && electron-builder --mac --x64",
  • Add this product name near the top-level main field:
  "productName": "Local Context Workspace",
  • Add this build configuration as a top-level field before the final closing brace:
  "build": {
    "appId": "com.nextwork.localcontext",
    "productName": "Local Context Workspace",
    "files": ["electron/**/*", "package.json"],
    "extraResources": [{ "from": "out", "to": "web" }],
    "mac": {
      "category": "public.app-category.productivity",
      "icon": "build/icon.svg",
      "target": [{ "target": "dmg", "arch": ["x64"] }]
    }
  }

What enters the application bundle?

  • files includes the Electron service and package metadata.
  • extraResources copies the exported Next.js site to Contents/Resources/web.
  • arch: ['x64'] prevents the builder from producing an Apple Silicon package.
  • Save package.json.
  • Confirm npm can read the new fields by running:
npm pkg get productName scripts.dist build.mac.target

The JSON output should show the product name, distribution script, and an x64 DMG target.

Packaging configuration not parsing?

Top-level fields need commas between them, while the last field before a closing brace does not. Use help me repair the packaging configuration.

Build, install, and pin the app

The first package build can take several minutes while electron-builder downloads its macOS tools. A quiet period during that download is expected.

  • Stop any development Electron process by pressing Control+C.
  • Build the Intel DMG by running:
npm run dist
  • Confirm the DMG exists by running:
ls dist/*.dmg

You should see one DMG path whose name begins with Local Context Workspace.

DMG build failing?

Read the first error above the final failure line. Icon conversion errors point to build/icon.svg, while missing web resources point to a failed npm run build step.

Help me diagnose the DMG build.

  • Open the dist folder in Finder.
  • Double-click the generated DMG.
  • Drag Local Context Workspace into the Applications folder shown in the installer window.

The app is unsigned because this project does not use an Apple Developer certificate. Only override Ventura's warning for the app you just built from your own source.

  • Open Applications in Finder.
  • Control-click Local Context Workspace.
  • Choose Open from the shortcut menu.
  • Click Open in the confirmation dialog.

The application icon should remain active in the Dock while its local service runs. Firefox should open the React Bits workspace in a new tab.

  • Quit the installed app from its Dock menu after the first successful launch.
  • Drag Local Context Workspace from Applications into the Dock.

Before clicking the pinned icon, do you expect the interface to open inside Electron or in Firefox?

  • Click the Local Context Workspace icon in the Dock.
  • Wait for Firefox to open the local workspace.
  • Send The Dock launch works. from the composer.

You should see the animated React Bits interface in Firefox, your earlier transcript, and a new recap-aware reply. The Dock icon now launches the full local application without Terminal.

  • Confirm the installed executable uses Intel architecture by running:
file "/Applications/Local Context Workspace.app/Contents/MacOS/Local Context Workspace"

The output should include x86_64. That is the final proof that the package matches your Intel Ventura Mac.

Dock launch not opening Firefox?

If the Dock icon starts but Firefox does not open, launch the app again from Applications and watch for a macOS warning. Use help me trace the packaged Firefox launch.

Secret mission

Add Native Dock Actions

Add native Dock actions that reopen the workspace in Firefox and reveal the saved transcript in Finder.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep the installed app and source, pause the local service, or delete everything you created. This project runs entirely on your Mac, so there are no ongoing costs.

Resources you used:

  • The ~/Desktop/local-context-workspace source directory, including the Next.js app, React Bits components, Electron service, icon, and DMG output.
  • The installed /Applications/Local Context Workspace.app Intel application.
  • The ~/Library/Application Support/Local Context Workspace data directory used by the packaged app.
  • The ~/Library/Application Support/local-context-workspace development data directory, if Electron created it during terminal testing.

Keep everything running

No action is needed. Choose this if you still use the Dock app or plan to keep developing its React Bits interface.

  • Keep Local Context Workspace in Applications and the Dock.
  • Keep ~/Desktop/local-context-workspace for future code changes.
  • Keep both Application Support directories so development and packaged transcripts remain available.

Pause - I'll come back to this later

Shut down the running Electron process to free memory. The app, source, transcript, and recap files remain ready for your next session.

  • Control-click the running Local Context Workspace icon in the Dock.
  • Choose Quit.
  • Leave the Applications copy installed.
  • Leave both Application Support directories untouched.

Delete - I don't want to use this again

Remove the installed app, source, and both possible local archives. These actions permanently erase the conversations stored in those directories.

  • Quit Local Context Workspace from its Dock menu.
  • Remove the pinned Dock icon by dragging it away from the Dock.
  • Delete the installed app by running:
rm -rf "/Applications/Local Context Workspace.app"
  • Delete the complete source directory by running:
rm -rf "$HOME/Desktop/local-context-workspace"
  • Delete the packaged app's transcript and recap archive by running:
rm -rf "$HOME/Library/Application Support/Local Context Workspace"
  • Delete the development archive if it exists by running:
rm -rf "$HOME/Library/Application Support/local-context-workspace"
  • Confirm the application and source are absent by running:
ls "/Applications"
ls "$HOME/Desktop"

The listings should no longer include Local Context Workspace.app or local-context-workspace.

Nice Work!

Nice Work!

You did it! You built an Intel Mac app that starts from the Dock, opens its animated local workspace in Firefox, and restores its conversation from files on your Mac.

You've learned how to:

  • Built a Next.js workspace with Aurora, Split Text, and Spotlight Card effects from React Bits.
  • Created a local Electron service that opens Firefox through the macOS default-browser handoff.
  • Packaged an Intel Dock app that restores a JSON transcript and timestamped recap files across restarts.
  • Completed the optional Secret Mission by adding native Dock actions for reopening Firefox and revealing the local archive in Finder.

Ready to quiz yourself?