Build a Local-First Writing Harness
Build a browser-local AI writing assistant with a traceable provider workspace.
Introduction
30 Second Summary
A rough paragraph can take longer to polish than it did to write. Sending every unfinished draft to an online service can also feel like a poor fit for private work.
In this project, you will build a local-first writing workbench with Next.js that rewrites rough passages through a browser Web Worker. You'll trace every browser-local run in an append-only event panel.
What You'll Build
Picture a rough passage becoming a clearer draft beside a live record of the browser model's work.
By the end of this project, you'll have:
- A local writing workbench that turns a rough passage into a clearer draft without sending it to an inference server.
- A responsive trajectory view that shows model loading progress plus each event from the current writing run.
- A replaceable provider workspace that shows Browser CPU as active, Ollama as unavailable, and Remote GPU as planned without contacting the Ollama endpoint.
- Secret Mission: Package the generated text into a structured Visual Studio Code handoff envelope for a future companion extension.
Are there any prerequisites?
You'll need Node.js 20.9 or newer, Visual Studio Code, npm, and Safari 16.4 or newer.
No Ollama installation or account is required.
Before We Start
This checkpoint defines how your local-first writing harness rewrites pasted passages through replaceable browser-local inference. Any future Visual Studio Code integration requires explicit approval before changing an editor selection or file.
Set Up the Local Harness
Browser-local inference still needs a reliable development environment. Your installed Node.js version has not been checked against the minimum required by Next.js.
This step creates the pinned app with its browser AI dependencies. You will open that app in Visual Studio Code before confirming the starter page in Safari.
In this step, get ready to:
- Verify Node.js 20.9 or newer.
- Create a pinned TypeScript project with the App Router.
- Install Transformers.js plus Motion for React before running the starter with Webpack.
Verify the Node.js version
Next.js requires Node.js 20.9 or newer. Checking the installed runtime now prevents confusing scaffold errors later.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check your installed Node.js version by running this command:
node --version
What does this command check?
The command asks Node.js to print its installed version. Compare the result with the required minimum of 20.9.
✔️ I see the required version
That check passes. Your Node.js runtime can support this Next.js project.
ⓧ I see an older version
Your current runtime is below the Next.js minimum. Upgrade it before creating the project.
- Visit the official Node.js download page.
- Download Node.js v24.20.0 LTS for macOS.
- Complete the Node.js installer.
- Return to Terminal after the installer closes.
- Check the upgraded version by running this command:
node --version
What should the new check show?
The printed version should now be 20.9 or newer. The current LTS release comfortably meets that requirement.
Still seeing the older version?
Close Terminal completely after the installation. Reopen it through Spotlight so the new session can detect the upgraded runtime.
If the older version remains active, help me identify which Node.js installation my Mac is using.
ⓧ Command not found
Your terminal cannot find Node.js yet. Install the current LTS release before continuing.
- Visit the official Node.js download page.
- Download Node.js v24.20.0 LTS for macOS.
- Complete the Node.js installer.
- Return to Terminal after the installer closes.
- Confirm the installation by running this command:
node --version
What should the installation confirm?
The terminal should print Node.js 20.9 or newer. That output confirms the runtime is available to the project tools.
Command still unavailable?
Close Terminal completely after the installation. Open a fresh Terminal session through Spotlight before trying the version check again.
If the command remains unavailable, help me make Node.js available in my macOS terminal.
Scaffold the Next.js project
The scaffold creates the project structure that later steps turn into the writing harness. Its options pin Next.js 16.3.4 with TypeScript plus the App Router.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
The tilde points to your macOS home folder. Starting from its Desktop directory gives the new project a predictable location that is easy to find.
The scaffold can take a few minutes while npm downloads the starter packages. A quiet pause during this first download is expected.
- Approve the temporary package download if npm asks for permission.
- Create the local-writing-harness project by running this command:
npx create-next-app@16.3.4 local-writing-harness --ts --tailwind --app --src-dir --use-npm --webpack --no-linter --import-alias "@/*"
What does the scaffold create?
- The command pins create-next-app to 16.3.4.
- The --ts option configures TypeScript.
- The --app option creates the App Router structure.
- The --src-dir option places the application beneath src.
- The --tailwind option includes Tailwind CSS.
- The --webpack option keeps the documented browser alias path available for the model worker.
The scaffold finishes by reporting that the project was created successfully. Your Desktop now contains a local-writing-harness folder.
Did the scaffold stop early?
Check that your terminal still shows a Node.js version of 20.9 or newer. Network interruptions can also stop npm while it downloads packages.
If the scaffold still fails, help me troubleshoot my create-next-app command.
- Move into the new project folder by running this command:
cd local-writing-harness
Where is the terminal now?
Terminal now points at the local-writing-harness folder on your Desktop. Every project command that follows runs against this scaffold.
Install dependencies and run the starter
The writing harness needs one library for browser-local model execution. It also needs one library for the interface animations added later.
This installation can take a short while because npm resolves the existing scaffold packages too. The completed command returns control to your terminal.
- Install the pinned writing and animation dependencies by running this command:
npm install @huggingface/transformers@4.2.0 motion@13.2.0
What are these dependencies for?
- The @huggingface/transformers package at 4.2.0 runs the later text generation pipeline inside the browser.
- The motion package at 13.2.0 animates generated results plus trajectory entries in a later step.
Did the dependency install fail?
Confirm that Terminal is still inside the local-writing-harness folder. A network interruption can also prevent npm from retrieving a package.
If npm reports another problem, help me diagnose the dependency installation.
- Open the current project folder in Visual Studio Code by running this command:
code .
What does the launcher do?
The code launcher asks Visual Studio Code to open the current folder. The dot represents the local-writing-harness directory that Terminal is already using.
✔️ Visual Studio Code opens
The launcher is ready. Visual Studio Code now shows the project files in its left sidebar.
ⓧ The code command is unavailable
The desktop app is installed without its terminal launcher. Visual Studio Code can add that launcher from its Command Palette.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
- Press Cmd+Shift+P to open the Command Palette.
- Type Shell Command: Install 'code' command in PATH into the Command Palette.
- Select the matching command.
- Return to Terminal from earlier.
- Open the project through the new launcher by running this command:
code .
What confirms the launcher works?
Visual Studio Code should open the current local-writing-harness folder. Its left sidebar should list files such as package.json plus the src folder.
Launcher still unavailable?
Close Terminal after installing the launcher. Open a fresh Terminal session so it can detect the new command.
If it remains unavailable, help me activate the Visual Studio Code launcher on macOS.
Pinned package versions make this environment reproducible. The final package.json records the exact runtime plus development toolchain used by the project.
- Select package.json in the Visual Studio Code file sidebar.
- Replace its contents with this pinned configuration:
{
"name": "local-writing-harness",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start"
},
"dependencies": {
"@huggingface/transformers": "4.2.0",
"motion": "13.2.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "26.5.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.7",
"tailwindcss": "4.3.3",
"typescript": "5.9.3"
}
}
What does this configuration control?
- The scripts section keeps the development and build commands on Webpack.
- The dependencies section pins the application runtime plus the browser inference libraries.
- The devDependencies section pins the TypeScript plus Tailwind CSS build toolchain.
- Save package.json by pressing Cmd+S.
Seeing a package file warning?
Compare every quotation mark plus comma with the reference below. JSON requires double quotes around each key.
If Visual Studio Code still highlights an error, help me find the JSON syntax problem in package.json.
✔️ Awesome, I've got everything!
Your pinned package configuration is saved. Continue to the local server check.
ⓧ I'd like to double check the full code
{
"name": "local-writing-harness",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start"
},
"dependencies": {
"@huggingface/transformers": "4.2.0",
"motion": "13.2.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "26.5.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.7",
"tailwindcss": "4.3.3",
"typescript": "5.9.3"
}
}
- Return to Terminal from earlier.
- Start the Next.js development server by running this command:
npm run dev
What does the development command do?
The dev script starts Next.js with Webpack. It keeps this Terminal session occupied while the local site is running.
Press Ctrl+C when you eventually want to stop the server. Keep it running for the verification below.
Pause for a prediction. Decide what Safari should display when the local address loads.
- Press Cmd+Space to open Spotlight.
- Type Safari into Spotlight.
- Press Enter to open Safari.
- Click the Safari address bar.
- Enter http://localhost:3000.
- Press Return to load the local app.
You will see the generated Next.js starter page. The Terminal running the server should remain free of errors.
Your local harness foundation is live. The pinned app now has the toolchain required for the browser-based workspace.
Starter page not loading?
Keep the Terminal running npm run dev open. Closing that process stops the local site.
Confirm that Safari uses http://localhost:3000 exactly. Check Terminal for the first reported error if the page still fails.
If the starter remains unavailable, help me diagnose why my Next.js starter page will not load.
The reproducible project environment is ready. Next, you will replace the starter page with the three-panel writing workspace.
Build the Harness Workspace
Your Next.js starter is running in Safari. It still presents a generic page with no place to draft or inspect a writing run.
A writing assistant needs an understandable workspace before it needs intelligence. React will render the visible session surfaces.
The layout borrows DeepSeek Harness's separation of replaceable capabilities from the session trace. Tailwind CSS will shape those surfaces into a responsive workspace.
The first rewrite control tests the interface boundary before a compute provider exists. That visible shortfall proves why the workspace needs a separate compute layer.
In this step, get ready to:
- Update the workspace metadata and page layout.
- Create the dark visual system for the harness surfaces.
- Build the provider panel, writing session, and trajectory surface.
Set workspace metadata
Page metadata gives the workspace a clear browser identity. The root layout also provides the shared document structure for every screen.
- In the left sidebar of Visual Studio Code, select src/app/layout.tsx.
- Replace the file contents with the following code:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Local Writing Harness",
description: "A local-first assisted writing workspace",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What does this layout do?
- The metadata object gives the browser tab a project-specific title.
- The description identifies the app as a local-first writing workspace.
- The RootLayout component wraps every page in the shared HTML structure.
- The globals.css import makes the harness styles available across the app.
- Save src/app/layout.tsx.
- Refresh http://localhost:3000 in Safari.
You should see Local Writing Harness as the browser tab title. Your page content still comes from the starter app.
Still seeing the old tab title?
Confirm that you edited src/app/layout.tsx. Check that the file is saved before refreshing Safari.
If the title remains unchanged, inspect the terminal for a TypeScript error from the layout file.
help me troubleshoot why my Next.js metadata title is not appearing in Safari
✔️ Awesome, I've got everything!
Your workspace metadata and root layout are saved.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Local Writing Harness",
description: "A local-first assisted writing workspace",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What to compare
Compare this reference with your saved file. Check the metadata values and the imported stylesheet.
Style the harness surfaces
A harness needs clear visual boundaries between providers, writing controls, and run history. The shared stylesheet creates those boundaries with reusable panel classes.
- Select src/app/globals.css in the left file tree.
- Replace the file contents with the following styles:
@import "tailwindcss";
:root {
--background: #090b10;
--panel: #11151d;
--panel-soft: #171c26;
--border: #283140;
--foreground: #eef2f7;
--muted: #98a3b3;
--accent: #7c9cff;
--success: #69d2a0;
--warning: #f1c27d;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
button, textarea { font: inherit; }
.harness-shell { min-height: 100vh; background: var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: var(--panel); }
.badge { border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
How does the visual system work?
- The variables in :root keep the workspace colors consistent.
- The .panel class separates each workspace surface with a border and dark background.
- The .primary-button class makes the rewrite action visually prominent.
- The .editor class gives the writing area enough height for a rough passage.
- Save src/app/globals.css.
- Refresh the running app in Safari.
You should see the starter page against a dark background. This confirms the shared stylesheet is active.
Still seeing a light background?
Confirm that src/app/layout.tsx imports ./globals.css. Check that the stylesheet begins with the Tailwind CSS import.
Look at the running terminal for a CSS parsing error if the page does not refresh.
help me find why my global CSS changes are not appearing in the Next.js app
✔️ Awesome, I've got everything!
Your dark harness visual system is active.
ⓧ I'd like to double check the full code
@import "tailwindcss";
:root {
--background: #090b10;
--panel: #11151d;
--panel-soft: #171c26;
--border: #283140;
--foreground: #eef2f7;
--muted: #98a3b3;
--accent: #7c9cff;
--success: #69d2a0;
--warning: #f1c27d;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
button, textarea { font: inherit; }
.harness-shell { min-height: 100vh; background: var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: var(--panel); }
.badge { border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
What to compare
Compare each color variable and reusable class with your saved stylesheet. Small punctuation differences can stop the browser from applying a rule.
Build the three-panel workspace
Client state lets the writing area respond as you type. It also gives the rewrite control a visible status message while the compute boundary remains empty.
- Select src/app/page.tsx in the left file tree.
- Replace the file contents with this runnable client shell:
"use client";
import { useState } from "react";
const starterText =
"Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const [source, setSource] = useState(starterText);
const [message, setMessage] = useState("No compute provider connected.");
return (
<main className="harness-shell p-4 md:p-6">
</main>
);
}
What does this client shell do?
- The client directive allows the page to use interactive React state.
- The source state stores the editable passage.
- The message state stores the current compute status.
- The Home component returns a valid shell for the workspace content.
- Save src/app/page.tsx.
- Refresh the running app in Safari.
You should see an empty dark page. This proves the client component compiles before you add the workspace surfaces.
Seeing a page error?
Check that the client directive is the first line of src/app/page.tsx. Confirm that both state declarations are inside the Home component.
help me fix the client component error in my Next.js page
The working shell proves that the state foundation compiles. The three workspace surfaces can now use that shared state without adding a compute provider.
- In src/app/page.tsx, locate these empty main element lines:
<main className="harness-shell p-4 md:p-6">
</main>
What are you replacing?
These lines currently render the empty dark shell. The replacement keeps the same outer element and fills it with the workspace panels.
- Replace those two lines with the following workspace markup:
<main className="harness-shell p-4 md:p-6">
<header className="mx-auto mb-4 max-w-[1500px]">
<p className="text-xs uppercase tracking-[0.24em] text-[var(--accent)]">Local-first workspace</p>
<h1 className="text-2xl font-semibold">Writing Harness</h1>
</header>
<div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]">
<aside className="panel p-4"><h2 className="text-sm font-semibold">Providers</h2><p className="mt-3 text-xs text-[var(--muted)]">Browser CPU — waiting for a compute layer</p><p className="mt-3 text-xs text-[var(--muted)]">Ollama — unavailable</p><p className="mt-3 text-xs text-[var(--muted)]">Remote GPU — planned</p></aside>
<section className="panel p-4 md:p-6">
<p className="text-xs text-[var(--muted)]">Session</p><h2 className="text-xl font-semibold">Clarify this passage</h2>
<label className="mb-2 mt-5 block text-sm font-medium" htmlFor="source-text">Source text</label>
<textarea id="source-text" className="editor" value={source} onChange={(event) => setSource(event.target.value)} />
<button className="primary-button mt-4" type="button" onClick={() => setMessage("No compute provider connected.")}>Rewrite locally</button>
<p className="mt-3 text-xs text-[var(--muted)]">{message}</p>
</section>
<aside className="panel p-4"><h2 className="text-sm font-semibold">Trajectory</h2><p className="mt-3 text-xs text-[var(--muted)]">Workspace ready</p><p className="mt-1 text-xs text-[var(--muted)]">No source text has left the browser.</p></aside>
</div>
</main>
How is the workspace organized?
- The header identifies the app as a local-first writing workspace.
- The left panel shows the current provider states.
- The center panel binds the textarea to the source state.
- The rewrite button updates only the visible provider message.
- The right panel establishes the trajectory surface with its first session event.
- Save src/app/page.tsx.
- Switch back to Safari.
- Refresh http://localhost:3000.
You should see a responsive three-panel workspace. The provider list sits on the left, the writing session fills the center, and the trajectory sits on the right.
Workspace panels missing or stacked?
A narrow browser window intentionally stacks the panels. Widen Safari to let the large-screen grid place all three panels side by side.
If a panel is missing, compare the opening and closing tags around the two aside elements and the central section element.
help me troubleshoot the three-panel layout in my React page
✔️ Awesome, I've got everything!
Your three-panel harness workspace is complete.
ⓧ I'd like to double check the full code
"use client";
import { useState } from "react";
const starterText =
"Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const [source, setSource] = useState(starterText);
const [message, setMessage] = useState("No compute provider connected.");
return (
<main className="harness-shell p-4 md:p-6">
<header className="mx-auto mb-4 max-w-[1500px]">
<p className="text-xs uppercase tracking-[0.24em] text-[var(--accent)]">Local-first workspace</p>
<h1 className="text-2xl font-semibold">Writing Harness</h1>
</header>
<div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]">
<aside className="panel p-4"><h2 className="text-sm font-semibold">Providers</h2><p className="mt-3 text-xs text-[var(--muted)]">Browser CPU — waiting for a compute layer</p><p className="mt-3 text-xs text-[var(--muted)]">Ollama — unavailable</p><p className="mt-3 text-xs text-[var(--muted)]">Remote GPU — planned</p></aside>
<section className="panel p-4 md:p-6">
<p className="text-xs text-[var(--muted)]">Session</p><h2 className="text-xl font-semibold">Clarify this passage</h2>
<label className="mb-2 mt-5 block text-sm font-medium" htmlFor="source-text">Source text</label>
<textarea id="source-text" className="editor" value={source} onChange={(event) => setSource(event.target.value)} />
<button className="primary-button mt-4" type="button" onClick={() => setMessage("No compute provider connected.")}>Rewrite locally</button>
<p className="mt-3 text-xs text-[var(--muted)]">{message}</p>
</section>
<aside className="panel p-4"><h2 className="text-sm font-semibold">Trajectory</h2><p className="mt-3 text-xs text-[var(--muted)]">Workspace ready</p><p className="mt-1 text-xs text-[var(--muted)]">No source text has left the browser.</p></aside>
</div>
</main>
);
}
What to compare
Compare the state declarations and all three workspace surfaces with your saved file. Check that the rewrite button updates message without calling another service.
- Edit the prefilled passage in the Source text area.
Before you press the rewrite control, do you think interface state alone can produce a revised passage?
- Click Rewrite locally.
You will see No compute provider connected. The workspace remains responsive because the control only updates local interface state.
That shortfall is intentional. The provider panel and trajectory are ready, but presentation alone cannot perform assisted writing.
Solid work. Your responsive workspace now exposes the compute gap that browser-local inference closes next.
Run the Writing Model in a Worker
Your workspace now makes the missing compute layer visible. The unconnected rewrite control proves that the interface cannot produce assisted writing on its own.
This step adds browser-local inference through a Web Worker. The worker runs Transformers.js away from the main interface so the workspace stays responsive.
In this step, get ready to:
- Configure the browser build for Transformers.js.
- Create the worker message contract and local model worker.
- Connect the writing workspace to the worker and verify a local rewrite.
Confirm the missing compute layer
The current rewrite control exposes the exact gap this step solves. Test that designed obstacle once before connecting the worker.
Before you press the control, do you expect the passage or the missing-provider message to appear?
- Return to Safari from earlier.
- Click Rewrite locally.
You will still see No compute provider connected. The workspace remains responsive because the control only updates interface state.
The browser build needs two aliases so server-only dependencies stay out of the worker bundle. Next.js applies these aliases through its Webpack configuration.
- Switch back to Visual Studio Code from earlier.
- Select next.config.ts in the Explorer sidebar.
- Replace the contents of next.config.ts with the configuration below:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
export default nextConfig;
What does this configuration do?
- The webpack function adjusts the bundle before Next.js compiles the app.
- The sharp$ alias disables an image-processing dependency that the browser worker does not need.
- The onnxruntime-node$ alias keeps the Node.js runtime out of the browser bundle.
- Save next.config.ts.
- Stop the existing development server by pressing Ctrl+C in the terminal.
- Restart the development server by running this command:
npm run dev
Why restart the server?
Next.js reads next.config.ts when the development server starts. Restarting activates the new browser aliases.
- Refresh the local workspace in Safari.
You should see the same three-panel workspace. This confirms that the Webpack aliases load without breaking the existing page.
Does the workspace fail to load?
Check that both aliases sit inside config.resolve.alias. Make sure the existing aliases are preserved with ...config.resolve.alias.
Confirm that every brace matches the reference block. A missing comma can stop Next.js from reading the configuration.
help me diagnose my next.config.ts Webpack alias configuration.
The page and worker need a shared message vocabulary. A small TypeScript contract keeps requests separate from worker responses.
- Right-click the src folder in the Explorer sidebar.
- Select New Folder.
- Enter lib as the folder name.
- Right-click the new lib folder.
- Select New File.
- Enter providers.ts as the file name.
- Add the message contract to src/lib/providers.ts by pasting the code below:
export type RewriteRequest = { type: "rewrite"; text: string };
export type WorkerMessage =
| { type: "state"; status: "loading" | "running"; detail: string }
| { type: "progress"; progress: number; detail: string }
| { type: "complete"; text: string }
| { type: "error"; message: string };
How does the message contract work?
- RewriteRequest describes the passage sent from the page to the worker.
- WorkerMessage lists every response that the page can receive.
- The union separates loading state, progress, completion, and errors by their type values.
- Save src/lib/providers.ts.
- Confirm that providers.ts appears inside src/lib in the Explorer sidebar.
Is the contract file in the wrong place?
The final path must be src/lib/providers.ts. Move the file into src/lib if it appears beside the folder.
help me correct the location of my providers.ts file.
Build the browser worker
The worker owns model loading and text generation. It loads Xenova/LaMini-Flan-T5-77M lazily so the model is requested only when the first rewrite begins.
- Right-click the app folder inside src in the Explorer sidebar.
- Select New File.
- Enter worker.ts as the file name.
- Add the local model worker to src/app/worker.ts by pasting the code below:
import { env, pipeline } from "@huggingface/transformers";
import type { RewriteRequest, WorkerMessage } from "@/lib/providers";
const MODEL_ID = "Xenova/LaMini-Flan-T5-77M";
env.allowLocalModels = false;
type TextGenerator = (input: string, options: { max_new_tokens: number }) => Promise<Array<{ generated_text: string }>>;
let generatorPromise: Promise<TextGenerator> | null = null;
function send(message: WorkerMessage) { self.postMessage(message); }
function getGenerator() {
if (!generatorPromise) {
send({ type: "state", status: "loading", detail: "Loading the browser model" });
generatorPromise = pipeline("text2text-generation", MODEL_ID, { progress_callback: (update) => { const progressUpdate = update as { progress?: number; file?: string; status?: string }; send({ type: "progress", progress: Math.round(progressUpdate.progress ?? 0), detail: progressUpdate.file ?? progressUpdate.status ?? "Loading model files" }); } }) as unknown as Promise<TextGenerator>;
}
return generatorPromise;
}
self.addEventListener("message", async (event: MessageEvent<RewriteRequest>) => {
if (event.data.type !== "rewrite") return;
try {
const generator = await getGenerator();
send({ type: "state", status: "running", detail: "Rewriting on this device" });
const prompt = ["Rewrite the following passage for clarity while preserving its meaning.", "Return only the revised passage.", "", event.data.text].join("\n");
const output = await generator(prompt, { max_new_tokens: 100 });
const text = output[0]?.generated_text?.trim();
if (!text) throw new Error("The local model returned no text.");
send({ type: "complete", text });
} catch (error) { send({ type: "error", message: error instanceof Error ? error.message : "Local inference failed." }); }
});
What does the worker do?
- env.allowLocalModels disables checks for model files bundled with the project.
- getGenerator() stores one pipeline promise so later rewrites can reuse the loaded model.
- The text2text-generation pipeline rewrites the passage with a limit of 100 new tokens.
- The message listener returns generated_text or a structured error to the page.
- Save src/app/worker.ts.
- Confirm that worker.ts appears inside src/app in the Explorer sidebar.
Does the worker file show type errors?
Check that the import path ends with @/lib/providers. Confirm that the model identifier matches the code block exactly.
help me troubleshoot the TypeScript errors in my browser worker.
The React page now needs to create the worker and listen for its messages. Each incoming state updates the interface or appends a trajectory entry.
- Select src/app/page.tsx in the Explorer sidebar.
- Replace the contents of src/app/page.tsx with the connected workspace below:
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RewriteRequest, WorkerMessage } from "@/lib/providers";
type RunStatus = "idle" | "loading" | "running" | "complete" | "error";
type TraceEntry = { id: string; title: string; detail: string; time: string };
const starterText = "Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const workerRef = useRef<Worker | null>(null);
const [source, setSource] = useState(starterText);
const [output, setOutput] = useState("");
const [status, setStatus] = useState<RunStatus>("idle");
const [progress, setProgress] = useState(0);
const [trace, setTrace] = useState<TraceEntry[]>([{ id: "workspace-ready", title: "Workspace ready", detail: "No source text has left the browser.", time: "Local session" }]);
const addTrace = useCallback((title: string, detail: string) => setTrace((current) => [...current, { id: `${Date.now()}-${current.length}`, title, detail, time: new Date().toLocaleTimeString() }]), []);
useEffect(() => { const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); workerRef.current = worker; const receive = (event: MessageEvent<WorkerMessage>) => { const message = event.data; if (message.type === "progress") { setProgress(message.progress); return; } if (message.type === "state") { setStatus(message.status); addTrace(message.status === "loading" ? "Model load started" : "Model ready", message.detail); return; } if (message.type === "complete") { setOutput(message.text); setProgress(100); setStatus("complete"); addTrace("Rewrite completed", "The worker returned generated_text."); return; } setStatus("error"); addTrace("Rewrite failed", message.message); }; worker.addEventListener("message", receive); return () => worker.terminate(); }, [addTrace]);
function rewrite() { const text = source.trim(); if (!text || !workerRef.current) return; setOutput(""); setProgress(0); setStatus("loading"); addTrace("Rewrite requested", "Sending text to the browser worker."); const request: RewriteRequest = { type: "rewrite", text }; workerRef.current.postMessage(request); }
const busy = status === "loading" || status === "running";
return <main className="harness-shell p-4 md:p-6"><div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]"><aside className="panel p-4"><h2>Providers</h2><p>Browser CPU</p><p>Ollama unavailable</p><p>Remote GPU planned</p></aside><section className="panel p-4 md:p-6"><h1 className="text-xl font-semibold">Clarify this passage</h1><textarea className="editor mt-4" value={source} onChange={(event) => setSource(event.target.value)} /><button className="primary-button mt-4" disabled={busy || source.trim().length === 0} onClick={rewrite}>{busy ? "Working locally..." : "Rewrite locally"}</button>{busy ? <div className="progress-track mt-4"><div className="progress-value" style={{ width: `${Math.max(progress, 4)}%` }} /></div> : null}{output ? <article className="mt-6"><p>Revised passage</p><p>{output}</p></article> : null}</section><aside className="panel p-4"><h2>Trajectory</h2>{trace.map((entry) => <article key={entry.id}><h3>{entry.title}</h3><p>{entry.detail}</p></article>)}</aside></div></main>;
}
How does the page communicate with the worker?
- useEffect() creates one module worker when the page loads.
- postMessage sends the trimmed source passage through the shared request contract.
- The message listener updates progress or places the returned text in the result panel.
- addTrace appends each event to the current trajectory without replacing earlier entries.
- Save src/app/page.tsx.
- Return to Safari.
The first run is the slow one because the browser downloads model assets from Hugging Face Hub. Allow a few minutes for that first download.
Before you test the connection, which event do you expect the trajectory to record first?
- Refresh the local workspace.
- Enter a short rough paragraph in the source editor.
- Click Rewrite locally.
You should see the control change to Working locally... while the worker runs. The result panel should display a revised passage after the model finishes.
The trajectory should add Model load started, Model ready, and Rewrite completed in order.
Does the rewrite fail to finish?
Keep the page open while the first model download completes. Confirm that your connection allows the browser to download model assets.
Check that Safari is version 16.4 or newer. An older browser can fail before the worker completes.
help me diagnose why my browser-local Transformers.js worker does not complete a rewrite.
Polish and verify the local run
The worker connection now functions. The remaining styles make loading progress visible and restore the finished harness appearance.
- Select src/app/globals.css in the Explorer sidebar.
- Replace the contents of src/app/globals.css with the styles below:
@import "tailwindcss";
:root { --background: #090b10; --panel: #11151d; --panel-soft: #171c26; --border: #283140; --foreground: #eef2f7; --muted: #98a3b3; --accent: #7c9cff; --success: #69d2a0; --warning: #f1c27d; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; }
button, textarea { font: inherit; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.harness-shell { min-height: 100vh; background: radial-gradient(circle at top left, rgba(124, 156, 255, 0.12), transparent 32rem), var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: rgba(17, 21, 29, 0.94); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22); }
.badge { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.5; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
.progress-track { height: 0.35rem; overflow: hidden; border-radius: 999px; background: #202735; }
.progress-value { height: 100%; background: linear-gradient(90deg, var(--accent), var(--success)); transition: width 180ms ease; }
What do these styles add?
- The focus rules make keyboard focus visible on the button and textarea.
- The shell gradient gives the workspace depth without changing its three-panel layout.
- The progress classes turn worker download updates into a visible loading bar.
- Save src/app/globals.css.
- Refresh the workspace in Safari.
You should see a soft blue glow in the upper-left background. The panels should have translucent surfaces and deeper shadows.
Are the new styles missing?
Confirm that layout.tsx still imports ./globals.css. Check that the stylesheet starts with the Tailwind import.
help me find why my updated harness styles are not appearing.
The layout adds the scaffolded Geist font variables to the document. These variables keep the app ready for later typography refinements.
- Select src/app/layout.tsx in the Explorer sidebar.
- Replace the contents of src/app/layout.tsx with the layout below:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = { title: "Local Writing Harness", description: "A local-first assisted writing workspace" };
export default function RootLayout({ children }: LayoutProps<"/">) {
return <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}><body>{children}</body></html>;
}
What does the layout preserve?
- The metadata keeps the browser title and project description from the earlier workspace.
- Geist and Geist_Mono attach font variables to the document element.
- RootLayout continues to wrap every page with the shared stylesheet.
- Save src/app/layout.tsx.
- Return to Safari.
Before the final run, do you expect the interface to freeze while the worker generates text?
- Refresh the local workspace.
- Enter a short rough paragraph in the source editor.
- Click Rewrite locally.
You should see loading progress while the interface remains responsive. The result panel should display a rewritten passage when the run completes.
The trajectory should end with Rewrite completed. Earlier events should remain above it in the order they occurred.
Is the final trajectory incomplete?
Confirm that the page adds its message listener before you press the rewrite control. Check that the worker sends its completion message after reading generated_text.
If the trajectory records a failure, use its detail to check the worker. The page already preserves that error as a local event.
help me trace a missing completion event between my Web Worker and page.
That is the hardest connection complete. Your workspace now performs a local rewrite without locking the interface.
What stays inside the browser?
The page sends passage text directly to the worker with postMessage. No Next.js route handler receives the passage.
The browser downloads model assets from Hugging Face Hub. Inference runs on the device without an Ollama request or a remote GPU request.
✔️ Awesome, I've got everything!
Your worker connection is complete. Double-check that every changed file is saved.
ⓧ I'd like to double check the full code
{
"name": "local-writing-harness",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start"
},
"dependencies": {
"@huggingface/transformers": "4.2.0",
"motion": "13.2.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "26.5.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.7",
"tailwindcss": "4.3.3",
"typescript": "5.9.3"
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
export default nextConfig;
@import "tailwindcss";
:root { --background: #090b10; --panel: #11151d; --panel-soft: #171c26; --border: #283140; --foreground: #eef2f7; --muted: #98a3b3; --accent: #7c9cff; --success: #69d2a0; --warning: #f1c27d; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; }
button, textarea { font: inherit; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.harness-shell { min-height: 100vh; background: radial-gradient(circle at top left, rgba(124, 156, 255, 0.12), transparent 32rem), var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: rgba(17, 21, 29, 0.94); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22); }
.badge { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.5; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
.progress-track { height: 0.35rem; overflow: hidden; border-radius: 999px; background: #202735; }
.progress-value { height: 100%; background: linear-gradient(90deg, var(--accent), var(--success)); transition: width 180ms ease; }
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = { title: "Local Writing Harness", description: "A local-first assisted writing workspace" };
export default function RootLayout({ children }: LayoutProps<"/">) {
return <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}><body>{children}</body></html>;
}
export type RewriteRequest = { type: "rewrite"; text: string };
export type WorkerMessage =
| { type: "state"; status: "loading" | "running"; detail: string }
| { type: "progress"; progress: number; detail: string }
| { type: "complete"; text: string }
| { type: "error"; message: string };
import { env, pipeline } from "@huggingface/transformers";
import type { RewriteRequest, WorkerMessage } from "@/lib/providers";
const MODEL_ID = "Xenova/LaMini-Flan-T5-77M";
env.allowLocalModels = false;
type TextGenerator = (input: string, options: { max_new_tokens: number }) => Promise<Array<{ generated_text: string }>>;
let generatorPromise: Promise<TextGenerator> | null = null;
function send(message: WorkerMessage) { self.postMessage(message); }
function getGenerator() {
if (!generatorPromise) {
send({ type: "state", status: "loading", detail: "Loading the browser model" });
generatorPromise = pipeline("text2text-generation", MODEL_ID, { progress_callback: (update) => { const progressUpdate = update as { progress?: number; file?: string; status?: string }; send({ type: "progress", progress: Math.round(progressUpdate.progress ?? 0), detail: progressUpdate.file ?? progressUpdate.status ?? "Loading model files" }); } }) as unknown as Promise<TextGenerator>;
}
return generatorPromise;
}
self.addEventListener("message", async (event: MessageEvent<RewriteRequest>) => {
if (event.data.type !== "rewrite") return;
try {
const generator = await getGenerator();
send({ type: "state", status: "running", detail: "Rewriting on this device" });
const prompt = ["Rewrite the following passage for clarity while preserving its meaning.", "Return only the revised passage.", "", event.data.text].join("\n");
const output = await generator(prompt, { max_new_tokens: 100 });
const text = output[0]?.generated_text?.trim();
if (!text) throw new Error("The local model returned no text.");
send({ type: "complete", text });
} catch (error) { send({ type: "error", message: error instanceof Error ? error.message : "Local inference failed." }); }
});
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RewriteRequest, WorkerMessage } from "@/lib/providers";
type RunStatus = "idle" | "loading" | "running" | "complete" | "error";
type TraceEntry = { id: string; title: string; detail: string; time: string };
const starterText = "Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const workerRef = useRef<Worker | null>(null);
const [source, setSource] = useState(starterText);
const [output, setOutput] = useState("");
const [status, setStatus] = useState<RunStatus>("idle");
const [progress, setProgress] = useState(0);
const [trace, setTrace] = useState<TraceEntry[]>([{ id: "workspace-ready", title: "Workspace ready", detail: "No source text has left the browser.", time: "Local session" }]);
const addTrace = useCallback((title: string, detail: string) => setTrace((current) => [...current, { id: `${Date.now()}-${current.length}`, title, detail, time: new Date().toLocaleTimeString() }]), []);
useEffect(() => { const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); workerRef.current = worker; const receive = (event: MessageEvent<WorkerMessage>) => { const message = event.data; if (message.type === "progress") { setProgress(message.progress); return; } if (message.type === "state") { setStatus(message.status); addTrace(message.status === "loading" ? "Model load started" : "Model ready", message.detail); return; } if (message.type === "complete") { setOutput(message.text); setProgress(100); setStatus("complete"); addTrace("Rewrite completed", "The worker returned generated_text."); return; } setStatus("error"); addTrace("Rewrite failed", message.message); }; worker.addEventListener("message", receive); return () => worker.terminate(); }, [addTrace]);
function rewrite() { const text = source.trim(); if (!text || !workerRef.current) return; setOutput(""); setProgress(0); setStatus("loading"); addTrace("Rewrite requested", "Sending text to the browser worker."); const request: RewriteRequest = { type: "rewrite", text }; workerRef.current.postMessage(request); }
const busy = status === "loading" || status === "running";
return <main className="harness-shell p-4 md:p-6"><div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]"><aside className="panel p-4"><h2>Providers</h2><p>Browser CPU</p><p>Ollama unavailable</p><p>Remote GPU planned</p></aside><section className="panel p-4 md:p-6"><h1 className="text-xl font-semibold">Clarify this passage</h1><textarea className="editor mt-4" value={source} onChange={(event) => setSource(event.target.value)} /><button className="primary-button mt-4" disabled={busy || source.trim().length === 0} onClick={rewrite}>{busy ? "Working locally..." : "Rewrite locally"}</button>{busy ? <div className="progress-track mt-4"><div className="progress-value" style={{ width: `${Math.max(progress, 4)}%` }} /></div> : null}{output ? <article className="mt-6"><p>Revised passage</p><p>{output}</p></article> : null}</section><aside className="panel p-4"><h2>Trajectory</h2>{trace.map((entry) => <article key={entry.id}><h3>{entry.title}</h3><p>{entry.detail}</p></article>)}</aside></div></main>;
}
Your writing workspace now runs its model in a browser worker and records each local run. Next up, you will turn that connection into a replaceable provider boundary with animated feedback.
Add Replaceable Providers and Traceable Runs
Your Web Worker can now rewrite text locally. The trajectory records each stage of that run.
The interface still depends on one compute implementation. Stable provider descriptors keep future Ollama support separate from the page. An append-only event boundary keeps every run traceable.
In this step, get ready to:
- Define typed descriptors for each compute provider.
- Render active and future providers from one catalog.
- Animate successful rewrites with an append-only trajectory.
Define the provider boundary
A provider descriptor gives the interface a stable shape for every compute option. Each provider can expose its identity and availability without changing the worker contract.
- In the Visual Studio Code Explorer sidebar, select src/lib/providers.ts.
- Add these provider types above RewriteRequest:
export type ProviderId = "browser" | "ollama" | "remote-gpu";
export type ProviderStatus = "ready" | "unavailable" | "planned";
export type ProviderDescriptor = {
id: ProviderId;
label: string;
status: ProviderStatus;
detail: string;
endpoint?: string;
};
What do these types control?
- ProviderId limits the catalog to the three provider identities used by this workspace.
- ProviderStatus gives the interface a predictable availability value for each card.
- ProviderDescriptor defines the shared fields that every provider card can render.
- endpoint stays optional because a planned provider does not need a configured destination.
Why use provider descriptors?
Direct model calls inside the page would spread provider-specific details across the interface. A descriptor catalog keeps those details behind one replaceable boundary.
The browser worker remains the only active compute path. Future providers can adopt the same boundary without rewriting the workspace.
- Add the provider catalog below WorkerMessage:
export const providers: ProviderDescriptor[] = [
{ id: "browser", label: "Browser CPU", status: "ready", detail: "Transformers.js through WebAssembly" },
{ id: "ollama", label: "Ollama", status: "unavailable", detail: "Placeholder only. No request is sent from this project.", endpoint: "http://localhost:11434/api" },
{ id: "remote-gpu", label: "Remote GPU", status: "planned", detail: "Provider-neutral slot for the later GPU deployment." },
];
What does the catalog reserve?
- Browser CPU is ready because the existing worker performs local inference.
- Ollama is unavailable. Its http://localhost:11434/api value is display-only.
- Remote GPU is planned. It has no endpoint configured.
- The catalog contains no request function. Displaying a provider cannot send passage text to it.
- Save src/lib/providers.ts.
Before you check the catalog, do you expect TypeScript to accept every provider as a valid ProviderDescriptor?
- Check the typed provider boundary by running this command:
npm run build
What does this check prove?
The build checks that every catalog entry matches the provider types. It also confirms that the existing worker messages still satisfy their shared contract.
You should see the production build complete without a TypeScript error. That confirms the provider catalog fits around the existing local rewrite path.
Build reporting a type error?
Check that each id value matches one option in ProviderId.
Check that each status value matches one option in ProviderStatus.
Help me fix the provider catalog type error.
✔️ Awesome, I've got everything!
Your provider boundary is typed and saved.
ⓧ I'd like to double check the full code
export type ProviderId = "browser" | "ollama" | "remote-gpu";
export type ProviderStatus = "ready" | "unavailable" | "planned";
export type ProviderDescriptor = {
id: ProviderId;
label: string;
status: ProviderStatus;
detail: string;
endpoint?: string;
};
export type RewriteRequest = {
type: "rewrite";
text: string;
};
export type WorkerMessage =
| { type: "state"; status: "loading" | "running"; detail: string }
| { type: "progress"; progress: number; detail: string }
| { type: "complete"; text: string }
| { type: "error"; message: string };
export const providers: ProviderDescriptor[] = [
{ id: "browser", label: "Browser CPU", status: "ready", detail: "Transformers.js through WebAssembly" },
{ id: "ollama", label: "Ollama", status: "unavailable", detail: "Placeholder only. No request is sent from this project.", endpoint: "http://localhost:11434/api" },
{ id: "remote-gpu", label: "Remote GPU", status: "planned", detail: "Provider-neutral slot for the later GPU deployment." },
];
Render the provider catalog
The catalog becomes useful when the page renders every descriptor through the same card structure. Motion for React also gives each new trace entry a visible arrival.
- Return to src/app/page.tsx in the editor.
- Replace the file contents with this workspace component:
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { providers, type RewriteRequest, type WorkerMessage } from "@/lib/providers";
type RunStatus = "idle" | "loading" | "running" | "complete" | "error";
type TraceEntry = { id: string; title: string; detail: string; time: string };
const starterText = "Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const workerRef = useRef<Worker | null>(null);
const [source, setSource] = useState(starterText);
const [output, setOutput] = useState("");
const [status, setStatus] = useState<RunStatus>("idle");
const [progress, setProgress] = useState(0);
const [trace, setTrace] = useState<TraceEntry[]>([{ id: "workspace-ready", title: "Workspace ready", detail: "No source text has left the browser.", time: "Local session" }]);
const addTrace = useCallback((title: string, detail: string) => { setTrace((current) => [...current, { id: `${Date.now()}-${current.length}`, title, detail, time: new Date().toLocaleTimeString() }]); }, []);
useEffect(() => { const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); workerRef.current = worker; const receive = (event: MessageEvent<WorkerMessage>) => { const message = event.data; if (message.type === "progress") { setProgress(message.progress); return; } if (message.type === "state") { setStatus(message.status); addTrace(message.status === "loading" ? "Model load started" : "Model ready", message.detail); return; } if (message.type === "complete") { setOutput(message.text); setProgress(100); setStatus("complete"); addTrace("Rewrite completed", "The worker returned generated_text."); return; } setStatus("error"); addTrace("Rewrite failed", message.message); }; worker.addEventListener("message", receive); return () => worker.terminate(); }, [addTrace]);
function rewrite() { const text = source.trim(); if (!text || !workerRef.current) return; setOutput(""); setProgress(0); setStatus("loading"); addTrace("Rewrite requested", "Sending text to the browser worker."); const request: RewriteRequest = { type: "rewrite", text }; workerRef.current.postMessage(request); }
const busy = status === "loading" || status === "running";
return <main className="harness-shell p-4 md:p-6"><header className="mx-auto mb-4 flex max-w-[1500px] items-center justify-between"><div><p className="text-xs uppercase tracking-[0.24em] text-[var(--accent)]">Local-first workspace</p><h1 className="text-2xl font-semibold">Writing Harness</h1></div><span className="badge" data-status="ready">Browser compute</span></header><div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]"><aside className="panel p-4"><h2 className="mb-3 text-sm font-semibold">Providers</h2><div className="space-y-3">{providers.map((provider) => <article key={provider.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel-soft)] p-3"><div className="mb-2 flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{provider.label}</h3><span className="badge" data-status={provider.status}>{provider.status}</span></div><p className="text-xs leading-5 text-[var(--muted)]">{provider.detail}</p>{provider.endpoint ? <code className="mt-2 block break-all text-[0.68rem] text-[var(--muted)]">{provider.endpoint}</code> : null}</article>)}</div></aside><section className="panel p-4 md:p-6"><div className="mb-5"><p className="text-xs text-[var(--muted)]">Session</p><h2 className="text-xl font-semibold">Clarify this passage</h2></div><label className="mb-2 block text-sm font-medium" htmlFor="source-text">Source text</label><textarea id="source-text" className="editor" value={source} onChange={(event) => setSource(event.target.value)} /><div className="mt-4 flex flex-wrap items-center gap-3"><button className="primary-button" type="button" disabled={busy || source.trim().length === 0} onClick={rewrite}>{busy ? "Working locally..." : "Rewrite locally"}</button><span className="text-xs text-[var(--muted)]">{status === "idle" ? "Ready for a local run" : status}</span></div>{busy ? <div className="mt-4"><div className="progress-track"><div className="progress-value" style={{ width: `${Math.max(progress, 4)}%` }} /></div></div> : null}<AnimatePresence>{output ? <motion.article key={output} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="mt-6 rounded-xl border border-[var(--border)] bg-[#0c1017] p-4"><p className="mb-2 text-xs uppercase tracking-[0.18em] text-[var(--success)]">Revised passage</p><p className="whitespace-pre-wrap leading-7">{output}</p></motion.article> : null}</AnimatePresence></section><aside className="panel p-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">Trajectory</h2><span className="text-xs text-[var(--muted)]">append-only</span></div><div className="space-y-3">{trace.map((entry) => <motion.article layout initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} key={entry.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel-soft)] p-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{entry.title}</h3><time className="text-[0.65rem] text-[var(--muted)]">{entry.time}</time></div><p className="mt-1 text-xs leading-5 text-[var(--muted)]">{entry.detail}</p></motion.article>)}</div></aside></div></main>;
}
How does the workspace stay replaceable?
- providers.map() renders each descriptor through one card structure.
- provider.endpoint only displays an endpoint when the descriptor includes one.
- workerRef.current.postMessage(request) remains the only compute dispatch in the page.
- motion.article animates new trace entries without replacing earlier events.
- AnimatePresence animates the generated result when output arrives.
- Save src/app/page.tsx.
- Return to Safari.
- Refresh http://localhost:3000.
You should see cards for Browser CPU, Ollama, and Remote GPU. Only the Ollama card should display http://localhost:11434/api.
Before you start a rewrite, do you expect any provider except Browser CPU to receive the passage?
- Enter a short rough passage in the Source text area.
- Click Rewrite locally.
You should see new events slide into the trajectory. The revised passage should fade into the result panel after the browser worker finishes.
Provider cards or animations missing?
Check that the import includes providers from @/lib/providers.
Check that AnimatePresence and motion come from motion/react.
Help me debug the provider cards or Motion animations.
✔️ Awesome, I've got everything!
Your page now renders the provider catalog and animates local run feedback.
ⓧ I'd like to double check the full code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { providers, type RewriteRequest, type WorkerMessage } from "@/lib/providers";
type RunStatus = "idle" | "loading" | "running" | "complete" | "error";
type TraceEntry = { id: string; title: string; detail: string; time: string };
const starterText = "Our product helps teams collect many different sources and then they can use those sources to produce useful writing without having to continually change between applications.";
export default function Home() {
const workerRef = useRef<Worker | null>(null);
const [source, setSource] = useState(starterText);
const [output, setOutput] = useState("");
const [status, setStatus] = useState<RunStatus>("idle");
const [progress, setProgress] = useState(0);
const [trace, setTrace] = useState<TraceEntry[]>([{ id: "workspace-ready", title: "Workspace ready", detail: "No source text has left the browser.", time: "Local session" }]);
const addTrace = useCallback((title: string, detail: string) => { setTrace((current) => [...current, { id: `${Date.now()}-${current.length}`, title, detail, time: new Date().toLocaleTimeString() }]); }, []);
useEffect(() => { const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); workerRef.current = worker; const receive = (event: MessageEvent<WorkerMessage>) => { const message = event.data; if (message.type === "progress") { setProgress(message.progress); return; } if (message.type === "state") { setStatus(message.status); addTrace(message.status === "loading" ? "Model load started" : "Model ready", message.detail); return; } if (message.type === "complete") { setOutput(message.text); setProgress(100); setStatus("complete"); addTrace("Rewrite completed", "The worker returned generated_text."); return; } setStatus("error"); addTrace("Rewrite failed", message.message); }; worker.addEventListener("message", receive); return () => worker.terminate(); }, [addTrace]);
function rewrite() { const text = source.trim(); if (!text || !workerRef.current) return; setOutput(""); setProgress(0); setStatus("loading"); addTrace("Rewrite requested", "Sending text to the browser worker."); const request: RewriteRequest = { type: "rewrite", text }; workerRef.current.postMessage(request); }
const busy = status === "loading" || status === "running";
return <main className="harness-shell p-4 md:p-6"><header className="mx-auto mb-4 flex max-w-[1500px] items-center justify-between"><div><p className="text-xs uppercase tracking-[0.24em] text-[var(--accent)]">Local-first workspace</p><h1 className="text-2xl font-semibold">Writing Harness</h1></div><span className="badge" data-status="ready">Browser compute</span></header><div className="mx-auto grid max-w-[1500px] gap-4 lg:grid-cols-[250px_minmax(0,1fr)_310px]"><aside className="panel p-4"><h2 className="mb-3 text-sm font-semibold">Providers</h2><div className="space-y-3">{providers.map((provider) => <article key={provider.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel-soft)] p-3"><div className="mb-2 flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{provider.label}</h3><span className="badge" data-status={provider.status}>{provider.status}</span></div><p className="text-xs leading-5 text-[var(--muted)]">{provider.detail}</p>{provider.endpoint ? <code className="mt-2 block break-all text-[0.68rem] text-[var(--muted)]">{provider.endpoint}</code> : null}</article>)}</div></aside><section className="panel p-4 md:p-6"><div className="mb-5"><p className="text-xs text-[var(--muted)]">Session</p><h2 className="text-xl font-semibold">Clarify this passage</h2></div><label className="mb-2 block text-sm font-medium" htmlFor="source-text">Source text</label><textarea id="source-text" className="editor" value={source} onChange={(event) => setSource(event.target.value)} /><div className="mt-4 flex flex-wrap items-center gap-3"><button className="primary-button" type="button" disabled={busy || source.trim().length === 0} onClick={rewrite}>{busy ? "Working locally..." : "Rewrite locally"}</button><span className="text-xs text-[var(--muted)]">{status === "idle" ? "Ready for a local run" : status}</span></div>{busy ? <div className="mt-4"><div className="progress-track"><div className="progress-value" style={{ width: `${Math.max(progress, 4)}%` }} /></div></div> : null}<AnimatePresence>{output ? <motion.article key={output} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="mt-6 rounded-xl border border-[var(--border)] bg-[#0c1017] p-4"><p className="mb-2 text-xs uppercase tracking-[0.18em] text-[var(--success)]">Revised passage</p><p className="whitespace-pre-wrap leading-7">{output}</p></motion.article> : null}</AnimatePresence></section><aside className="panel p-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">Trajectory</h2><span className="text-xs text-[var(--muted)]">append-only</span></div><div className="space-y-3">{trace.map((entry) => <motion.article layout initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} key={entry.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel-soft)] p-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{entry.title}</h3><time className="text-[0.65rem] text-[var(--muted)]">{entry.time}</time></div><p className="mt-1 text-xs leading-5 text-[var(--muted)]">{entry.detail}</p></motion.article>)}</div></aside></div></main>;
}
Style statuses and verify the build
Status styling turns the provider catalog into a readable control surface. Ready compute uses the success colour. Unavailable compute uses the warning colour.
- Return to src/app/globals.css in the editor.
- Replace the file contents with these workspace styles:
@import "tailwindcss";
:root {
--background: #090b10;
--panel: #11151d;
--panel-soft: #171c26;
--border: #283140;
--foreground: #eef2f7;
--muted: #98a3b3;
--accent: #7c9cff;
--success: #69d2a0;
--warning: #f1c27d;
}
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; }
button, textarea { font: inherit; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.harness-shell { min-height: 100vh; background: radial-gradient(circle at top left, rgba(124, 156, 255, 0.12), transparent 32rem), var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: rgba(17, 21, 29, 0.94); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22); }
.badge { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.badge[data-status="ready"] { border-color: rgba(105, 210, 160, 0.5); color: var(--success); }
.badge[data-status="unavailable"] { border-color: rgba(241, 194, 125, 0.45); color: var(--warning); }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.5; }
.secondary-button { border: 1px solid var(--border); border-radius: 0.75rem; background: var(--panel-soft); color: var(--foreground); cursor: pointer; padding: 0.7rem 0.9rem; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
.progress-track { height: 0.35rem; overflow: hidden; border-radius: 999px; background: #202735; }
.progress-value { height: 100%; background: linear-gradient(90deg, var(--accent), var(--success)); transition: width 180ms ease; }
What do the status styles show?
- data-status="ready" gives Browser CPU the success colour.
- data-status="unavailable" gives Ollama the warning colour.
- planned keeps the neutral badge style because no endpoint is configured.
- secondary-button defines a quieter control style for later workspace actions.
- Save src/app/globals.css.
- Return to Safari.
- Refresh http://localhost:3000.
You should see Browser CPU styled as ready. Ollama should use the unavailable warning colour. Remote GPU should remain neutral.
Status colours still look identical?
Check that the badge receives data-status={provider.status} in src/app/page.tsx.
Check that both attribute selectors appear in src/app/globals.css.
Help me debug the provider badge status colours.
✔️ Awesome, I've got everything!
Your provider states now have distinct visual treatments.
ⓧ I'd like to double check the full code
@import "tailwindcss";
:root {
--background: #090b10;
--panel: #11151d;
--panel-soft: #171c26;
--border: #283140;
--foreground: #eef2f7;
--muted: #98a3b3;
--accent: #7c9cff;
--success: #69d2a0;
--warning: #f1c27d;
}
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; }
button, textarea { font: inherit; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.harness-shell { min-height: 100vh; background: radial-gradient(circle at top left, rgba(124, 156, 255, 0.12), transparent 32rem), var(--background); }
.panel { border: 1px solid var(--border); border-radius: 1rem; background: rgba(17, 21, 29, 0.94); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22); }
.badge { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.55rem; color: var(--muted); font-size: 0.75rem; }
.badge[data-status="ready"] { border-color: rgba(105, 210, 160, 0.5); color: var(--success); }
.badge[data-status="unavailable"] { border-color: rgba(241, 194, 125, 0.45); color: var(--warning); }
.primary-button { border: 0; border-radius: 0.75rem; background: var(--accent); color: #07101f; cursor: pointer; padding: 0.75rem 1rem; font-weight: 700; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.5; }
.secondary-button { border: 1px solid var(--border); border-radius: 0.75rem; background: var(--panel-soft); color: var(--foreground); cursor: pointer; padding: 0.7rem 0.9rem; }
.editor { min-height: 15rem; width: 100%; resize: vertical; border: 1px solid var(--border); border-radius: 0.9rem; background: #0c1017; color: var(--foreground); padding: 1rem; line-height: 1.65; }
.progress-track { height: 0.35rem; overflow: hidden; border-radius: 999px; background: #202735; }
.progress-value { height: 100%; background: linear-gradient(90deg, var(--accent), var(--success)); transition: width 180ms ease; }
- Return to the integrated terminal from earlier.
- Stop the running development server by pressing Ctrl+C.
Before you build the finished workspace, do you expect the provider catalog and animation imports to pass the production checks?
- Build the completed project by running this command:
npm run build
What does the final build verify?
The production build checks the shared provider types and the client component. It also bundles the browser worker through the existing Webpack configuration.
You should see the build finish without an error. This proves the replaceable provider boundary compiles with the local worker path.
Final build failing?
Check the first reported file path. A provider error usually points to src/lib/providers.ts or its import in src/app/page.tsx.
Check that motion and AnimatePresence are imported from motion/react.
Help me diagnose the final Next.js build failure.
- Start the local app again by running this command:
npm run dev
What does this start?
The development server serves the workspace through the scaffolded Webpack script. Keep this terminal running while you perform the final browser check.
- Return to Safari.
- Refresh http://localhost:3000.
- Enter a short rough paragraph in the Source text area.
- Click Rewrite locally.
You should see Browser CPU as ready. Ollama should appear as unavailable. Remote GPU should appear as planned.
The revised passage should animate into view. The append-only trajectory should finish with Rewrite completed.
That completes the provider boundary. Your browser worker still performs the rewrite while the other providers remain visible placeholders.
Secret mission
Create the VS Code Handoff Envelope
Give each generated rewrite a structured route toward a future Visual Studio Code companion. You will copy a typed handoff envelope while keeping every editor change behind explicit user approval.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your Mac with no ongoing costs. Choose whether to keep it running, pause the local processes, or delete the project files.
Resources you used:
- The local-writing-harness project folder with its source code and installed project packages.
- The local development server process used to run the writing harness.
Keep everything running
Leave everything in place if you plan to continue building the harness. The local server can stay active while you work.
- Keep the local-writing-harness folder on your Mac.
- Continue using the active browser session while the development server remains running.
- Switch back to the project in Visual Studio Code when you want to make another change.
Pause - I'll come back to this later
Pause the local processes to free their memory. Your project files remain available for another session.
- Close the local writing harness browser tab.
- Switch back to the terminal running the development server.
- Press Ctrl+C to stop the server.
Your Web Worker is now stopped. The local-writing-harness folder remains ready for your next session.
Delete - I don't want to use this again
Remove the local project folder when you no longer need the harness. This clears the project code and its installed packages from your Mac.
Deleting the folder is permanent. Only your local project files are affected because this project created no cloud resources.
- Close the local writing harness browser tab.
- Switch back to the terminal running the development server.
- Press Ctrl+C if the server is still running.
- Delete the local-writing-harness folder by running these commands from the terminal inside that folder:
cd ..
rm -rf local-writing-harness
What Does This Remove?
The first command moves the terminal to the folder above your project. The second command permanently deletes the project folder.
The disabled Ollama card created no external resource. The planned Remote GPU slot also created nothing to delete.
- Verify the deletion by listing the remaining folders:
ls
The listing should no longer include local-writing-harness. Your local project files are fully removed.
Still See the Project Folder?
Check whether the deletion command reported a permissions problem. Confirm that you ran it from the terminal that previously opened inside the project folder.
Help me safely delete the remaining local-writing-harness folder.
Nice Work!
Nice Work!
You made it! Your Next.js writing harness now rewrites passages with browser-local inference inside a Web Worker. This first project gives the rest of the series a working local-first foundation.
You've learned how to:
- Run browser-local text generation with Transformers.js on the Browser CPU. The session stays responsive throughout the run.
- Define provider choices through a typed provider catalog in TypeScript. Browser CPU stays ready. Ollama stays unavailable. Remote GPU stays planned.
- Inspect an animated append-only trajectory for every local rewrite. Motion for React animates each new event without replacing earlier entries.
- Secret Mission: Export a structured handoff envelope for Visual Studio Code to the clipboard. The contract leaves any future editor change to a companion extension with user approval.
Ready to quiz yourself?