Build a Firefox App Exporter
Export NextWork apps as portable ZIP workspaces with a Firefox extension.
Introduction
30 Second Summary
A working website can be awkward to continue once you move it somewhere else. Copying pieces by hand can leave important parts behind.
In this project, you will build a Firefox sidebar for turning a completed NextWork app into a portable developer workspace. The sidebar can use an optional improvement API before downloading the workspace as one ZIP.
What You'll Build
You will see a working NextWork app turn into a single-root workspace that opens as a complete local project.
By the end of this project, you'll have:
- Review before sending: A private capture that keeps rendered text and code blocks editable until you approve an external request.
- Refinement controls: Preserve the original feature while applying the bug fixes or visual polish named in your brief.
- A runnable export: Download one project ZIP that installs with npm. It also runs locally and produces a production build.
- Secret Mission: Export the same project in Preserve mode and Polish UI mode. Compare how the stronger design brief changes the summary and workspace tree.
Are there any prerequisites?
You need a completed NextWork website or web app open in Firefox version 140 or later.
Your Intel Mac also needs Node.js version 22.22.3, npm version 10.9.8, and Visual Studio Code.
Before We Start
Before any hands-on work, set the boundary for how your completed NextWork project may change. This commitment protects its working behavior while identifying structural or visual details for the optional API to refine.
Set Up the Firefox-First Extension
Your completed NextWork project needs a separate exporter codebase. This boundary keeps the working source untouched while the exporter grows around it.
You will use WXT to build a Firefox-first extension. The default development command will launch Firefox from the beginning.
In this step, get ready to:
- Create an independent project folder for the exporter.
- Pin the extension dependencies and Firefox manifest settings.
- Launch the sidebar interface in a Firefox development profile.
Create the independent project folder
The nextwork-app-exporter folder gives the extension its own project root. You will keep every exporter file inside this folder.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Create the project folder on your Desktop by running these commands:
cd ~/Desktop
mkdir nextwork-app-exporter
cd nextwork-app-exporter
What do these commands do?
- The first command moves Terminal to your Desktop.
- The second command creates the nextwork-app-exporter folder.
- The third command moves Terminal into the new folder.
- Confirm your current location by running this command:
pwd
What should the path show?
You should see a path ending in Desktop/nextwork-app-exporter. This confirms that future commands will affect the independent exporter folder.
- Press Cmd+Space to open Spotlight again.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
- Drag the nextwork-app-exporter folder from your Desktop into Visual Studio Code.
The Explorer view now shows nextwork-app-exporter as the open folder. Your exporter has a clean home of its own.
The extension toolchain depends on Node.js and the npm command. This project uses Node.js 22.22.3 with npm 10.9.8.
Before you run the version check, predict whether both installed versions will match the project versions.
- Switch back to Terminal from earlier.
- Check the installed Node.js and npm versions by running:
node -v && npm -v
What does this check do?
The first command prints the installed Node.js version. The second command runs only after the first check succeeds.
The next line prints the installed npm version. These two values determine which setup path you need.
✔️ I see the expected versions
Your terminal shows Node.js v22.22.3 followed by npm 10.9.8. Your existing toolchain is ready for the extension.
ⓧ I see an older version
The installed runtime is older than the project version. Update it before installing the extension dependencies.
- Open the official Node.js archive in your browser.
- Download node-v22.22.3.pkg for macOS.
- Run the downloaded installer package.
- Return to Terminal from earlier.
- Check the updated versions by running:
node -v && npm -v
What confirms the update?
The output should now show Node.js v22.22.3 followed by npm 10.9.8. The updated runtime can support the pinned project tools.
ⓧ Command not found
Your shell cannot find Node.js yet. Install the Intel-compatible macOS package before continuing.
- Open the official Node.js archive in your browser.
- Download node-v22.22.3.pkg for macOS.
- Run the downloaded installer package.
- Return to Terminal from earlier.
- Check the installed versions by running:
node -v && npm -v
What confirms the installation?
The output should show Node.js v22.22.3 followed by npm 10.9.8. Both commands are now available to the project.
Configure the Firefox-first extension shell
The package manifest pins WXT 0.21.4, TypeScript 7.0.2, and JSZip 3.10.2. Its default development script selects Firefox.
- Switch back to Visual Studio Code.
- Select the nextwork-app-exporter folder in the Explorer view.
- Click New File... in the Explorer controls.
- Enter package.json as the file name.
The Explorer now lists package.json inside nextwork-app-exporter.
- Fill package.json with the pinned package configuration by pasting this code:
{
"name": "nextwork-app-exporter",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt -b firefox",
"dev:firefox": "wxt -b firefox",
"build:firefox": "wxt build -b firefox",
"zip:firefox": "wxt zip -b firefox",
"build:chromium": "wxt build",
"build:safari": "wxt build -b safari",
"postinstall": "wxt prepare"
},
"dependencies": {
"jszip": "3.10.2"
},
"devDependencies": {
"typescript": "7.0.2",
"wxt": "0.21.4"
}
}
What does this package file control?
- The dev script makes Firefox the default development target.
- The build scripts prepare separate Firefox, Chromium, and Safari targets.
- The dependency versions keep every learner on the same toolchain.
- The postinstall script lets WXT prepare generated TypeScript support files.
- Save package.json.
- Switch back to Terminal from earlier.
- Install the pinned project dependencies by running:
npm i
What does this installation do?
npm reads package.json and installs the exact dependency versions. The installation also runs WXT preparation through the postinstall script.
The dependency installation should complete without a terminal error. Your extension toolchain is now available inside nextwork-app-exporter.
Dependency installation failed?
Confirm that Terminal is still inside Desktop/nextwork-app-exporter. Check that your Node.js and npm versions match the successful setup path.
Check that package.json matches the code above. A missing quote or comma prevents npm from reading the file.
Ask for targeted help with the terminal output: Help me diagnose my npm installation error.
- Switch back to Visual Studio Code.
- Select the nextwork-app-exporter folder in the Explorer view.
- Click New File... in the Explorer controls.
- Enter tsconfig.json as the file name.
The Explorer now lists tsconfig.json beside package.json.
- Connect TypeScript to WXT's generated configuration by pasting this code into tsconfig.json:
{
"extends": ".wxt/tsconfig.json"
}
What does this configuration do?
This file extends the TypeScript settings generated by WXT. The extension can now use WXT's types without duplicating its compiler configuration.
- Save tsconfig.json.
- Select the nextwork-app-exporter folder in the Explorer view.
- Click New File... in the Explorer controls.
- Enter wxt.config.ts as the file name.
The new wxt.config.ts file will define the extension manifest for each browser target.
- Configure the extension manifest by pasting this code into wxt.config.ts:
import { defineConfig } from "wxt";
export default defineConfig({
manifest: ({ browser, manifestVersion }) => ({
name: "NextWork App Exporter",
description:
"Refine a working NextWork web project and export it as a portable app workspace.",
permissions: ["scripting"],
host_permissions: ["https://nextwork.ai/*"],
...(manifestVersion === 2
? { optional_permissions: ["https://*/*"] }
: { optional_host_permissions: ["https://*/*"] }),
...(browser === "firefox"
? {
browser_specific_settings: {
gecko: {
strict_min_version: "140.0",
data_collection_permissions: {
required: ["websiteContent"],
},
},
},
}
: {}),
}),
});
What does this manifest configuration do?
- The scripting permission supports page capture after the learner requests it.
- The https://nextwork.ai/* host permission limits built-in page access to NextWork.
- External HTTPS origins remain optional until the learner approves an endpoint.
- The Firefox settings require version 140.0 or later for website-content consent.
- Save wxt.config.ts.
Add the sidebar and launch Firefox
A WXT side-panel entrypoint turns an entrypoints/sidepanel/index.html file into the browser sidebar. The interface separates capture, improvement, preview, and export into four numbered panels.
- Select the nextwork-app-exporter folder in the Explorer view.
- Click New File... in the Explorer controls.
- Enter entrypoints/sidepanel/index.html as the file path.
Visual Studio Code creates the nested entrypoints/sidepanel folders with the new index.html file.
- Create the sidebar document shell by pasting this code into entrypoints/sidepanel/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="manifest.open_at_install" content="true" />
<title>NextWork App Exporter</title>
</head>
<body>
<main>
<header>
<p class="eyebrow">Firefox-first workspace exporter</p>
<h1>NextWork App Exporter</h1>
<p>Preserve the working idea. Improve the experience. Export the whole app.</p>
</header>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
What does the document shell provide?
- The metadata gives the sidebar a responsive viewport.
- The WXT metadata asks Firefox to open the sidebar when the extension is installed.
- The header states the exporter's behavior-preservation goal.
- Add the capture panel directly below the closing </header> line by pasting:
<section>
<h2>1. Capture the working project</h2>
<button id="capture-button" type="button">Capture current project</button>
<p id="capture-summary" class="status">No project captured.</p>
<label for="project-text">Reviewed project source</label>
<textarea id="project-text" rows="11" placeholder="Captured project material appears here."></textarea>
</section>
What does the capture panel contain?
The panel provides a capture button and an editable review area. Later code will fill these elements with the completed NextWork project's source material.
- Save entrypoints/sidepanel/index.html.
Before you start the development server, predict which browser WXT will open from the default script.
- Switch back to Terminal from earlier.
- Start the Firefox development extension by running:
npm run dev
What does the development command do?
The dev script runs WXT with Firefox as its selected browser. WXT builds the extension and opens a Firefox development profile.
The side-panel metadata opens the NextWork App Exporter sidebar. You should see its heading and capture controls.
That is the first working slice complete. Firefox is running the independent extension from your new project folder.
Sidebar did not open?
Keep the development command running in Terminal. Confirm that the terminal output does not report a problem in package.json or wxt.config.ts.
Check that index.html is inside entrypoints/sidepanel. WXT uses that exact location to create the sidebar entrypoint.
Ask for help with the visible terminal output: Help me troubleshoot why my WXT sidebar did not open in Firefox.
- Switch back to Visual Studio Code while the development server keeps running.
- Add the initial improvement panel above the closing </main> line by pasting:
<section>
<h2>2. Choose the improvement</h2>
<label for="mode">Improvement mode</label>
<select id="mode">
<option value="preserve">Preserve behavior and organize</option>
<option value="repair">Fix bugs if found</option>
<option value="polish">Polish UI</option>
<option value="repair-polish">Fix bugs and polish UI</option>
</select>
<label for="brief">Improvement brief</label>
<textarea
id="brief"
rows="5"
placeholder="Keep the core behavior. Improve hierarchy, spacing, responsive layout, and loading or error states."
></textarea>
</section>
How long can the prompt be?
The Improvement brief field has no maxlength attribute, so the extension does not cap how much text you enter. Its rows setting controls the initial height only.
The selected API receives the complete trimmed prompt. The provider's request-size, context-window, and pricing rules still apply.
- Save entrypoints/sidepanel/index.html.
- Return to the Firefox sidebar.
You should now see the improvement mode selector and the Improvement brief field below the capture panel. You can enter a long prompt without an extension-enforced character cap.
- Insert the endpoint controls directly after the improvement brief by pasting:
<label for="endpoint">HTTPS improvement endpoint</label>
<input id="endpoint" type="url" placeholder="https://api.example.com/improve" />
<label for="api-token">Bearer token, optional and never stored</label>
<input id="api-token" type="password" autocomplete="off" />
<label class="consent-row">
<input id="consent" type="checkbox" />
I reviewed this project and approve sending it to the endpoint above.
</label>
<div class="button-row">
<button id="improve-button" type="button">Improve project</button>
<button id="demo-button" type="button" class="secondary">Load polished demo</button>
</div>
Why are these controls separate?
The endpoint and optional token belong to the learner's chosen service. The consent checkbox keeps transmission behind an explicit user action.
The demo button provides a free built-in path for testing the later workspace flow.
- Save entrypoints/sidepanel/index.html.
- Return to the Firefox sidebar.
You should now see the endpoint, token, consent, improvement, and demo controls inside the second panel.
- Add the workspace preview panel above the closing </main> line by pasting:
<section>
<h2>3. Preview the finished workspace</h2>
<p id="improvement-summary" class="summary-box">No improved workspace loaded.</p>
<ul id="change-list" class="change-list"></ul>
<ul id="file-tree" class="file-tree"></ul>
</section>
What will the preview show?
The summary area will explain the refinement. The two lists will show concrete changes and the complete workspace tree.
- Save entrypoints/sidepanel/index.html.
- Return to the Firefox sidebar.
You should now see the third panel with an empty change summary and workspace preview.
- Add the export panel and live status area above the closing </main> line by pasting:
<section>
<h2>4. Export</h2>
<button id="download-button" type="button" disabled>Download workspace</button>
<p class="status">
The extension packages returned code as files. It does not execute the app inside the sidebar.
</p>
</section>
<p id="status" class="status" role="status" aria-live="polite">Ready.</p>
What does the export panel protect?
The download button begins disabled because no prepared workspace exists yet. The status region will report capture, improvement, and ZIP progress without executing returned source code.
- Save entrypoints/sidepanel/index.html.
- Return to the Firefox sidebar.
You should now see all four numbered panels. The download button remains disabled until a workspace is prepared in a later step.
The sidebar stylesheet gives the narrow browser panel a card-based layout. It also defines a dark color scheme for systems that prefer it.
- Select the sidepanel folder in the Explorer view.
- Click New File... in the Explorer controls.
- Enter style.css as the file name.
The Explorer now lists style.css beside index.html.
- Add the sidebar's global layout rules by pasting this code into entrypoints/sidepanel/style.css:
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #f4f4f5;
color: #18181b;
}
* {
box-sizing: border-box;
}
body {
min-width: 350px;
margin: 0;
}
main {
display: grid;
gap: 12px;
padding: 14px;
}
What do the global rules control?
These rules establish the sidebar font, colors, minimum width, and spacing grid. Universal border sizing keeps form controls inside the narrow panel.
- Save entrypoints/sidepanel/style.css.
- Continue the stylesheet with the card and heading rules by appending:
header,
section {
border: 1px solid #d4d4d8;
border-radius: 14px;
background: #ffffff;
padding: 14px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 6px;
font-size: 21px;
}
h2 {
margin-bottom: 10px;
font-size: 15px;
}
What do the card rules control?
The header and each workflow section become separate cards. The heading sizes preserve a clear hierarchy within the compact sidebar.
- Save entrypoints/sidepanel/style.css.
- Add the eyebrow and form-label styling by appending:
.eyebrow {
margin-bottom: 4px;
color: #6d28d9;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
label {
display: grid;
gap: 5px;
margin-top: 10px;
font-size: 12px;
font-weight: 700;
}
What do these text rules do?
The eyebrow identifies the exporter's Firefox-first purpose. The label rules keep every form value close to its description.
- Save entrypoints/sidepanel/style.css.
- Add the shared form-control rules by appending:
input,
textarea,
select,
button {
font: inherit;
}
input,
textarea,
select {
width: 100%;
border: 1px solid #a1a1aa;
border-radius: 9px;
background: #ffffff;
color: #18181b;
padding: 9px;
}
textarea {
resize: vertical;
}
What do the form rules control?
Inputs inherit the sidebar typography and fill the available width. Text areas remain vertically resizable for longer project material and improvement briefs.
- Save entrypoints/sidepanel/style.css.
- Add the primary and secondary button states by appending:
button {
border: 0;
border-radius: 9px;
background: #6d28d9;
color: #ffffff;
cursor: pointer;
padding: 10px 12px;
font-weight: 800;
}
button.secondary {
border: 1px solid #a1a1aa;
background: #ffffff;
color: #18181b;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
What do the button states communicate?
Primary buttons use the exporter's purple accent. Secondary and disabled styles make alternative or unavailable actions visually distinct.
- Save entrypoints/sidepanel/style.css.
- Add the paired-button and consent layouts by appending:
.button-row {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
margin-top: 12px;
}
.consent-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-weight: 500;
}
.consent-row input {
width: auto;
margin-top: 2px;
}
What do these layouts improve?
The two action buttons share a balanced row. The consent checkbox stays aligned with its full explanation.
- Save entrypoints/sidepanel/style.css.
- Add the status and summary presentation by appending:
.status,
.summary-box {
margin: 10px 0 0;
color: #52525b;
font-size: 12px;
line-height: 1.5;
}
.summary-box {
border-left: 3px solid #6d28d9;
background: #faf5ff;
padding: 9px;
}
What do the status styles highlight?
Compact status text keeps progress messages readable. The bordered summary box gives the API's future change summary a stronger visual position.
- Save entrypoints/sidepanel/style.css.
- Add the change-list styling by appending:
.change-list,
.file-tree {
display: grid;
gap: 6px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.change-list li {
padding-left: 15px;
font-size: 12px;
}
.change-list li::before {
content: "+";
margin-left: -15px;
margin-right: 7px;
color: #6d28d9;
font-weight: 800;
}
What does the change list show?
The list removes default bullets and adds a purple plus marker. Each future workspace change will read as a compact improvement note.
- Save entrypoints/sidepanel/style.css.
- Add the workspace file-tree styling by appending:
.file-tree li {
border-radius: 7px;
background: #f4f4f5;
padding: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
overflow-wrap: anywhere;
}
What does the file-tree style do?
Each workspace path appears in a compact code-style row. Long nested paths wrap within the sidebar instead of widening it.
- Save entrypoints/sidepanel/style.css.
- Finish the stylesheet with the dark color scheme by appending:
@media (prefers-color-scheme: dark) {
:root {
background: #18181b;
color: #fafafa;
}
header,
section,
input,
textarea,
select,
button.secondary {
background: #27272a;
color: #fafafa;
border-color: #52525b;
}
.status,
.summary-box {
color: #d4d4d8;
}
.summary-box {
background: #3b0764;
}
.file-tree li {
background: #3f3f46;
}
}
How does dark mode work?
The media query follows the operating system's preferred color scheme. It adjusts cards, controls, status text, summaries, and file rows together.
- Save entrypoints/sidepanel/style.css.
Seeing a stylesheet error?
Check that each selector keeps its opening and closing braces. Confirm that the dark-mode closing brace remains at the end of style.css.
Ask for help comparing the stylesheet chunks: Help me find the syntax problem in my sidebar CSS.
Use the reference below if you want to compare every setup file before the final check.
✔️ Awesome, I've got everything!
Great. Save every open file before returning to Firefox.
ⓧ I'd like to double check the full code
{
"name": "nextwork-app-exporter",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt -b firefox",
"dev:firefox": "wxt -b firefox",
"build:firefox": "wxt build -b firefox",
"zip:firefox": "wxt zip -b firefox",
"build:chromium": "wxt build",
"build:safari": "wxt build -b safari",
"postinstall": "wxt prepare"
},
"dependencies": {
"jszip": "3.10.2"
},
"devDependencies": {
"typescript": "7.0.2",
"wxt": "0.21.4"
}
}
This is the complete pinned package manifest for the extension shell.
{
"extends": ".wxt/tsconfig.json"
}
This is the complete TypeScript configuration for the WXT project.
import { defineConfig } from "wxt";
export default defineConfig({
manifest: ({ browser, manifestVersion }) => ({
name: "NextWork App Exporter",
description:
"Refine a working NextWork web project and export it as a portable app workspace.",
permissions: ["scripting"],
host_permissions: ["https://nextwork.ai/*"],
...(manifestVersion === 2
? { optional_permissions: ["https://*/*"] }
: { optional_host_permissions: ["https://*/*"] }),
...(browser === "firefox"
? {
browser_specific_settings: {
gecko: {
strict_min_version: "140.0",
data_collection_permissions: {
required: ["websiteContent"],
},
},
},
}
: {}),
}),
});
This is the complete cross-browser manifest configuration with Firefox-specific consent settings.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="manifest.open_at_install" content="true" />
<title>NextWork App Exporter</title>
</head>
<body>
<main>
<header>
<p class="eyebrow">Firefox-first workspace exporter</p>
<h1>NextWork App Exporter</h1>
<p>Preserve the working idea. Improve the experience. Export the whole app.</p>
</header>
<section>
<h2>1. Capture the working project</h2>
<button id="capture-button" type="button">Capture current project</button>
<p id="capture-summary" class="status">No project captured.</p>
<label for="project-text">Reviewed project source</label>
<textarea id="project-text" rows="11" placeholder="Captured project material appears here."></textarea>
</section>
<section>
<h2>2. Choose the improvement</h2>
<label for="mode">Improvement mode</label>
<select id="mode">
<option value="preserve">Preserve behavior and organize</option>
<option value="repair">Fix bugs if found</option>
<option value="polish">Polish UI</option>
<option value="repair-polish">Fix bugs and polish UI</option>
</select>
<label for="brief">Improvement brief</label>
<textarea
id="brief"
rows="5"
placeholder="Keep the core behavior. Improve hierarchy, spacing, responsive layout, and loading or error states."
></textarea>
<label for="endpoint">HTTPS improvement endpoint</label>
<input id="endpoint" type="url" placeholder="https://api.example.com/improve" />
<label for="api-token">Bearer token, optional and never stored</label>
<input id="api-token" type="password" autocomplete="off" />
<label class="consent-row">
<input id="consent" type="checkbox" />
I reviewed this project and approve sending it to the endpoint above.
</label>
<div class="button-row">
<button id="improve-button" type="button">Improve project</button>
<button id="demo-button" type="button" class="secondary">Load polished demo</button>
</div>
</section>
<section>
<h2>3. Preview the finished workspace</h2>
<p id="improvement-summary" class="summary-box">No improved workspace loaded.</p>
<ul id="change-list" class="change-list"></ul>
<ul id="file-tree" class="file-tree"></ul>
</section>
<section>
<h2>4. Export</h2>
<button id="download-button" type="button" disabled>Download workspace</button>
<p class="status">
The extension packages returned code as files. It does not execute the app inside the sidebar.
</p>
</section>
<p id="status" class="status" role="status" aria-live="polite">Ready.</p>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
This is the complete sidebar markup with capture, improvement, preview, and export controls.
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #f4f4f5;
color: #18181b;
}
* {
box-sizing: border-box;
}
body {
min-width: 350px;
margin: 0;
}
main {
display: grid;
gap: 12px;
padding: 14px;
}
header,
section {
border: 1px solid #d4d4d8;
border-radius: 14px;
background: #ffffff;
padding: 14px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 6px;
font-size: 21px;
}
h2 {
margin-bottom: 10px;
font-size: 15px;
}
.eyebrow {
margin-bottom: 4px;
color: #6d28d9;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
label {
display: grid;
gap: 5px;
margin-top: 10px;
font-size: 12px;
font-weight: 700;
}
input,
textarea,
select,
button {
font: inherit;
}
input,
textarea,
select {
width: 100%;
border: 1px solid #a1a1aa;
border-radius: 9px;
background: #ffffff;
color: #18181b;
padding: 9px;
}
textarea {
resize: vertical;
}
button {
border: 0;
border-radius: 9px;
background: #6d28d9;
color: #ffffff;
cursor: pointer;
padding: 10px 12px;
font-weight: 800;
}
button.secondary {
border: 1px solid #a1a1aa;
background: #ffffff;
color: #18181b;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.button-row {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
margin-top: 12px;
}
.consent-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-weight: 500;
}
.consent-row input {
width: auto;
margin-top: 2px;
}
.status,
.summary-box {
margin: 10px 0 0;
color: #52525b;
font-size: 12px;
line-height: 1.5;
}
.summary-box {
border-left: 3px solid #6d28d9;
background: #faf5ff;
padding: 9px;
}
.change-list,
.file-tree {
display: grid;
gap: 6px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.change-list li {
padding-left: 15px;
font-size: 12px;
}
.change-list li::before {
content: "+";
margin-left: -15px;
margin-right: 7px;
color: #6d28d9;
font-weight: 800;
}
.file-tree li {
border-radius: 7px;
background: #f4f4f5;
padding: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
overflow-wrap: anywhere;
}
@media (prefers-color-scheme: dark) {
:root {
background: #18181b;
color: #fafafa;
}
header,
section,
input,
textarea,
select,
button.secondary {
background: #27272a;
color: #fafafa;
border-color: #52525b;
}
.status,
.summary-box {
color: #d4d4d8;
}
.summary-box {
background: #3b0764;
}
.file-tree li {
background: #3f3f46;
}
}
This is the complete light-and-dark sidebar stylesheet.
Before the final check, predict which capture, improvement, preview, and export controls should be visible in the sidebar.
- Return to the Firefox development profile.
- Open the NextWork App Exporter sidebar.
You should see the NextWork App Exporter heading followed by four numbered panels. The panels contain capture, improvement, preview, and download controls.
The exporter shell is live. Firefox is now the default development target for every feature you add next.
Your independent Firefox-first extension is ready. Next up, you will capture a working NextWork project inside the sidebar without sending its content anywhere.
Capture the Working Project
Your Firefox-first WXT sidebar is running. You now have an independent extension shell ready to work with a real project.
A completed NextWork project already works. Capturing it locally gives you a reliable baseline before any refinement.
Tutorial-oriented output can mix app content with lesson copy. Reviewing the capture reveals the structural or visual limitation you want to improve.
In this step, get ready to:
- Define the data structure for captured project material.
- Collect the active page title, URL, rendered text, and visible code blocks.
- Review the captured source inside the sidebar without sending it externally.
Define the capture data
A CapturedProject object keeps the page title, URL, rendered text, and code blocks together. This TypeScript contract gives the capture function and sidebar one shared shape.
- In the Visual Studio Code Explorer sidebar, click the New Folder icon.
- Enter lib as the folder name.
- Select the lib folder in the Explorer sidebar.
- Click the New File icon.
- Enter contracts.ts as the file name.
You can now see lib/contracts.ts in the Explorer. That visible file confirms the shared contract has a home.
- Add the shared capture and workspace types to lib/contracts.ts by pasting this code:
export type ImprovementMode = "preserve" | "repair" | "polish" | "repair-polish";
export type CodeBlock = {
index: number;
language: string;
text: string;
};
export type CapturedProject = {
title: string;
url: string;
text: string;
codeBlocks: CodeBlock[];
};
export type WorkspaceFile = { path: string; content: string };
export type WorkspaceResponse = {
projectName: string;
summary: string;
changes: string[];
environment: string[];
files: WorkspaceFile[];
};
export type PreparedWorkspace = WorkspaceResponse & {
rootName: string;
};
What do these types describe?
- The CodeBlock type records each block's order, detected language, and visible text.
- The CapturedProject type groups all material collected from one active page.
- The workspace types provide shared state for the sidebar's preview and export panels.
- Save lib/contracts.ts.
- Check the running development terminal. You should see WXT complete its automatic rebuild without a TypeScript error.
- Finish lib/contracts.ts by adding the prepared local workspace fixture below the existing types:
export const POLISHED_DEMO: WorkspaceResponse = {
projectName: "nextwork-polished-demo",
summary: "Preserved the original one-page behavior while replacing the flat lesson-style presentation with a responsive app shell and clearer visual hierarchy.",
changes: [
"Separated markup, behavior, and visual styling into an intentional source tree.",
"Added a responsive card layout, stronger spacing, and visible interaction states.",
"Added standard install, development, build, and preview scripts.",
],
environment: [],
files: [
{ path: "package.json", content: JSON.stringify({ name: "nextwork-polished-demo", version: "1.0.0", private: true, type: "module", scripts: { dev: "vite", build: "vite build", preview: "vite preview" }, devDependencies: { vite: "8.3.3" } }, null, 2) },
{ path: "README.md", content: "# NextWork Polished Demo\n\nA portable Vite workspace produced by the NextWork App Exporter demo.\n\n## Run locally\n\n1. Run `npm install`.\n2. Run `npm run dev`.\n3. Open the local URL shown by Vite.\n\n## Production build\n\nRun `npm run build`. The output is written to `dist`.\n" },
{ path: ".gitignore", content: "node_modules\ndist\n.env\n.env.*\n!.env.example\n" },
{ path: "index.html", content: "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>NextWork Polished Demo</title>\n</head>\n<body>\n <main id=\"app\"></main>\n <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n</html>\n" },
{ path: "src/main.js", content: "import \"./style.css\";\n\nconst app = document.querySelector(\"#app\");\nconst card = document.createElement(\"section\");\nconst label = document.createElement(\"p\");\nconst heading = document.createElement(\"h1\");\nconst copy = document.createElement(\"p\");\nconst button = document.createElement(\"button\");\n\nlabel.className = \"eyebrow\";\nlabel.textContent = \"NextWork export ready\";\nheading.textContent = \"A functional project, presented like a finished app.\";\ncopy.textContent = \"The core idea stays intact while the workspace and interface become easier to continue building.\";\nbutton.textContent = \"Try the interaction\";\nbutton.addEventListener(\"click\", () => {\n button.textContent = \"Interaction works\";\n});\n\ncard.append(label, heading, copy, button);\napp?.append(card);\n" },
{ path: "src/style.css", content: ":root { font-family: Inter, system-ui, sans-serif; color: #f8fafc; background: #0f172a; }\n* { box-sizing: border-box; }\nbody { margin: 0; min-width: 320px; min-height: 100vh; }\n#app { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #312e81, #0f172a 58%); }\nsection { width: min(680px, 100%); padding: clamp(28px, 7vw, 64px); border: 1px solid #475569; border-radius: 28px; background: rgba(15, 23, 42, 0.82); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }\n.eyebrow { color: #c4b5fd; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }\nh1 { margin: 12px 0; font-size: clamp(2.2rem, 7vw, 4.8rem); line-height: 0.98; }\np { color: #cbd5e1; line-height: 1.65; }\nbutton { margin-top: 18px; border: 0; border-radius: 999px; padding: 13px 19px; background: #8b5cf6; color: white; font: inherit; font-weight: 800; cursor: pointer; }\nbutton:hover { background: #a78bfa; }\nbutton:focus-visible { outline: 3px solid #ddd6fe; outline-offset: 3px; }\n" },
],
};
Why keep this fixture in the contract file?
The POLISHED_DEMO value follows the same WorkspaceResponse contract as a future API response. Keeping both shapes together lets the sidebar use one workspace format.
- Save lib/contracts.ts.
- Check the running development terminal again. You should see the extension rebuild successfully.
Seeing a TypeScript error?
Check that POLISHED_DEMO appears after the closing brace for PreparedWorkspace. Confirm that each string keeps its surrounding quotation marks.
Ask for help with the contract file if the rebuild still fails.
✔️ Awesome, I've got everything!
Your lib/contracts.ts file now defines the captured project and workspace data shapes.
ⓧ I'd like to double check the full code
export type ImprovementMode = "preserve" | "repair" | "polish" | "repair-polish";
export type CodeBlock = {
index: number;
language: string;
text: string;
};
export type CapturedProject = {
title: string;
url: string;
text: string;
codeBlocks: CodeBlock[];
};
export type WorkspaceFile = {
path: string;
content: string;
};
export type WorkspaceResponse = {
projectName: string;
summary: string;
changes: string[];
environment: string[];
files: WorkspaceFile[];
};
export type PreparedWorkspace = WorkspaceResponse & {
rootName: string;
};
export const POLISHED_DEMO: WorkspaceResponse = {
projectName: "nextwork-polished-demo",
summary:
"Preserved the original one-page behavior while replacing the flat lesson-style presentation with a responsive app shell and clearer visual hierarchy.",
changes: [
"Separated markup, behavior, and visual styling into an intentional source tree.",
"Added a responsive card layout, stronger spacing, and visible interaction states.",
"Added standard install, development, build, and preview scripts.",
],
environment: [],
files: [
{
path: "package.json",
content: JSON.stringify(
{
name: "nextwork-polished-demo",
version: "1.0.0",
private: true,
type: "module",
scripts: {
dev: "vite",
build: "vite build",
preview: "vite preview",
},
devDependencies: {
vite: "8.3.3",
},
},
null,
2,
),
},
{
path: "README.md",
content:
"# NextWork Polished Demo\n\nA portable Vite workspace produced by the NextWork App Exporter demo.\n\n## Run locally\n\n1. Run `npm install`.\n2. Run `npm run dev`.\n3. Open the local URL shown by Vite.\n\n## Production build\n\nRun `npm run build`. The output is written to `dist`.\n",
},
{
path: ".gitignore",
content: "node_modules\ndist\n.env\n.env.*\n!.env.example\n",
},
{
path: "index.html",
content:
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>NextWork Polished Demo</title>\n</head>\n<body>\n <main id=\"app\"></main>\n <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n</html>\n",
},
{
path: "src/main.js",
content:
"import \"./style.css\";\n\nconst app = document.querySelector(\"#app\");\nconst card = document.createElement(\"section\");\nconst label = document.createElement(\"p\");\nconst heading = document.createElement(\"h1\");\nconst copy = document.createElement(\"p\");\nconst button = document.createElement(\"button\");\n\nlabel.className = \"eyebrow\";\nlabel.textContent = \"NextWork export ready\";\nheading.textContent = \"A functional project, presented like a finished app.\";\ncopy.textContent = \"The core idea stays intact while the workspace and interface become easier to continue building.\";\nbutton.textContent = \"Try the interaction\";\nbutton.addEventListener(\"click\", () => {\n button.textContent = \"Interaction works\";\n});\n\ncard.append(label, heading, copy, button);\napp?.append(card);\n",
},
{
path: "src/style.css",
content:
":root { font-family: Inter, system-ui, sans-serif; color: #f8fafc; background: #0f172a; }\n* { box-sizing: border-box; }\nbody { margin: 0; min-width: 320px; min-height: 100vh; }\n#app { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, #312e81, #0f172a 58%); }\nsection { width: min(680px, 100%); padding: clamp(28px, 7vw, 64px); border: 1px solid #475569; border-radius: 28px; background: rgba(15, 23, 42, 0.82); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }\n.eyebrow { color: #c4b5fd; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }\nh1 { margin: 12px 0; font-size: clamp(2.2rem, 7vw, 4.8rem); line-height: 0.98; }\np { color: #cbd5e1; line-height: 1.65; }\nbutton { margin-top: 18px; border: 0; border-radius: 999px; padding: 13px 19px; background: #8b5cf6; color: white; font: inherit; font-weight: 800; cursor: pointer; }\nbutton:hover { background: #a78bfa; }\nbutton:focus-visible { outline: 3px solid #ddd6fe; outline-offset: 3px; }\n",
},
],
};
How to use this reference
Compare this full file with lib/contracts.ts. Match every type name, property, fixture path, and content value.
Read the active project page
The capture begins inside the sidebar after the learner clicks its button. The extension then uses the WebExtensions APIs to query the active tab and run a small collection function inside that page.
The injected function reads visible page material as structured data. It does not contact an external endpoint.
- Select the lib folder in the Visual Studio Code Explorer sidebar.
- Click the New File icon.
- Enter capture.ts as the file name.
The new lib/capture.ts file is now visible beside the contract file.
- Build the active-page capture function in lib/capture.ts by pasting this code:
import { browser } from "wxt/browser";
import type { CapturedProject } from "./contracts";
export async function captureActiveProject(): Promise<CapturedProject> {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) throw new Error("No active tab was found.");
const results = await browser.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const codeBlocks = Array.from(document.querySelectorAll("pre code")).map((node, index) => {
const languageClass = Array.from(node.classList).find((className) => className.startsWith("language-"));
return {
index: index + 1,
language: languageClass?.replace("language-", "") ?? "",
text: node.textContent ?? "",
};
});
return {
title: document.title,
url: window.location.href,
text: document.body?.innerText.trim() ?? "",
codeBlocks,
};
},
});
const captured = results.find((result) => result.frameId === 0)?.result as CapturedProject | undefined;
if (!captured?.text) throw new Error("The active page returned no project text.");
return captured;
}
What does the capture function do?
- The browser.tabs.query() call identifies the active tab in the current Firefox window.
- The browser.scripting.executeScript() call runs the collector inside that tab after the sidebar requests it.
- The pre code selector finds visible code elements and records their order, language class, and text.
- The final check rejects an empty page capture before it reaches the sidebar state.
- Save lib/capture.ts.
- Check the running development terminal. You should see WXT rebuild the extension without a capture-module error.
Capture module not compiling?
Confirm that the imports point to wxt/browser and ./contracts. Check that captureActiveProject() closes after returning captured.
Ask for help comparing the capture function if the terminal still reports an error.
✔️ Awesome, I've got everything!
Your capture module can now collect structured source material from the active page.
ⓧ I'd like to double check the full code
import { browser } from "wxt/browser";
import type { CapturedProject } from "./contracts";
export async function captureActiveProject(): Promise<CapturedProject> {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id == null) throw new Error("No active tab was found.");
const results = await browser.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const codeBlocks = Array.from(document.querySelectorAll("pre code")).map(
(node, index) => {
const languageClass = Array.from(node.classList).find((className) =>
className.startsWith("language-"),
);
return {
index: index + 1,
language: languageClass?.replace("language-", "") ?? "",
text: node.textContent ?? "",
};
},
);
return {
title: document.title,
url: window.location.href,
text: document.body?.innerText.trim() ?? "",
codeBlocks,
};
},
});
const captured = results.find((result) => result.frameId === 0)?.result as
| CapturedProject
| undefined;
if (!captured?.text) throw new Error("The active page returned no project text.");
return captured;
}
How to use this reference
Compare the full function with lib/capture.ts. Pay close attention to the active-tab query, injected function, and top-frame result selection.
Connect capture to the sidebar
The capture module can read a page, but the sidebar still needs to control when that work begins. Its event handler keeps capture behind an explicit button click and places the returned text in the editable review area.
- Select the entrypoints/sidepanel folder in the Visual Studio Code Explorer sidebar.
- Click the New File icon.
- Enter main.ts as the file name.
You can now see entrypoints/sidepanel/main.ts beside the sidebar HTML and stylesheet.
- Initialize the sidebar references and state in entrypoints/sidepanel/main.ts by pasting this code:
import { captureActiveProject } from "../../lib/capture";
import {
type CapturedProject,
type PreparedWorkspace,
} from "../../lib/contracts";
import "./style.css";
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing interface element: ${id}`);
return element as T;
}
const captureButton = byId<HTMLButtonElement>("capture-button");
const captureSummary = byId<HTMLParagraphElement>("capture-summary");
const projectText = byId<HTMLTextAreaElement>("project-text");
const mode = byId<HTMLSelectElement>("mode");
const brief = byId<HTMLTextAreaElement>("brief");
const endpoint = byId<HTMLInputElement>("endpoint");
const apiToken = byId<HTMLInputElement>("api-token");
const consent = byId<HTMLInputElement>("consent");
const improveButton = byId<HTMLButtonElement>("improve-button");
const demoButton = byId<HTMLButtonElement>("demo-button");
const improvementSummary = byId<HTMLParagraphElement>("improvement-summary");
const changeList = byId<HTMLUListElement>("change-list");
const fileTree = byId<HTMLUListElement>("file-tree");
const downloadButton = byId<HTMLButtonElement>("download-button");
const status = byId<HTMLParagraphElement>("status");
How are the controls connected?
The generic byId() helper retrieves an existing sidebar element and stops immediately if its ID is missing. The typed constants give each control the correct browser element interface.
- Save entrypoints/sidepanel/main.ts.
- Check the development terminal. You should see WXT rebuild while the sidebar remains loaded in Firefox.
- Add the capture and workspace state below the element references in entrypoints/sidepanel/main.ts by pasting this code:
let capturedProject: CapturedProject | null = null;
let workspace: PreparedWorkspace | null = null;
function setStatus(message: string): void {
status.textContent = message;
}
function clearWorkspace(): void {
workspace = null;
downloadButton.disabled = true;
improvementSummary.textContent = "No improved workspace loaded.";
changeList.replaceChildren();
fileTree.replaceChildren();
}
Why clear the workspace during capture?
A fresh capture becomes the new source baseline. The clearWorkspace() function removes any older preview state so a previous workspace cannot appear connected to the new page.
- Save entrypoints/sidepanel/main.ts.
- Confirm the development terminal completes another rebuild without a sidebar-state error.
- Finish the local review flow below clearWorkspace() by pasting this code:
captureButton.addEventListener("click", async () => {
clearWorkspace();
setStatus("Capturing the working NextWork project...");
try {
capturedProject = await captureActiveProject();
projectText.value = capturedProject.text;
captureSummary.textContent = `${capturedProject.title} | ${capturedProject.text.length.toLocaleString()} characters | ${capturedProject.codeBlocks.length} code block(s)`;
setStatus("Capture complete. Preserve the behavior and describe only the improvements you want.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Capture failed.");
}
});
projectText.addEventListener("input", () => {
if (capturedProject) capturedProject = { ...capturedProject, text: projectText.value };
});
What happens after the click?
- The click handler clears any older workspace before starting the page capture.
- A successful capture fills the editable project-text area and summarizes the title, character count, and code-block count.
- The input handler keeps capturedProject.text synchronized with every review edit.
- The content remains inside the extension because this flow does not call an endpoint.
- Save entrypoints/sidepanel/main.ts.
- Confirm the development terminal completes the rebuild without a TypeScript error.
✔️ Awesome, I've got everything!
Your sidebar now connects its capture button to the active-page collector and editable review area.
ⓧ I'd like to double check the full code
import { captureActiveProject } from "../../lib/capture";
import {
type CapturedProject,
type PreparedWorkspace,
} from "../../lib/contracts";
import "./style.css";
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing interface element: ${id}`);
return element as T;
}
const captureButton = byId<HTMLButtonElement>("capture-button");
const captureSummary = byId<HTMLParagraphElement>("capture-summary");
const projectText = byId<HTMLTextAreaElement>("project-text");
const mode = byId<HTMLSelectElement>("mode");
const brief = byId<HTMLTextAreaElement>("brief");
const endpoint = byId<HTMLInputElement>("endpoint");
const apiToken = byId<HTMLInputElement>("api-token");
const consent = byId<HTMLInputElement>("consent");
const improveButton = byId<HTMLButtonElement>("improve-button");
const demoButton = byId<HTMLButtonElement>("demo-button");
const improvementSummary = byId<HTMLParagraphElement>("improvement-summary");
const changeList = byId<HTMLUListElement>("change-list");
const fileTree = byId<HTMLUListElement>("file-tree");
const downloadButton = byId<HTMLButtonElement>("download-button");
const status = byId<HTMLParagraphElement>("status");
let capturedProject: CapturedProject | null = null;
let workspace: PreparedWorkspace | null = null;
function setStatus(message: string): void {
status.textContent = message;
}
function clearWorkspace(): void {
workspace = null;
downloadButton.disabled = true;
improvementSummary.textContent = "No improved workspace loaded.";
changeList.replaceChildren();
fileTree.replaceChildren();
}
captureButton.addEventListener("click", async () => {
clearWorkspace();
setStatus("Capturing the working NextWork project...");
try {
capturedProject = await captureActiveProject();
projectText.value = capturedProject.text;
captureSummary.textContent = `${capturedProject.title} | ${capturedProject.text.length.toLocaleString()} characters | ${capturedProject.codeBlocks.length} code block(s)`;
setStatus("Capture complete. Preserve the behavior and describe only the improvements you want.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Capture failed.");
}
});
projectText.addEventListener("input", () => {
if (capturedProject) capturedProject = { ...capturedProject, text: projectText.value };
});
How to use this reference
Compare this file with entrypoints/sidepanel/main.ts. The final line should close the editable-text input handler.
Before you click Capture current project, do you expect the summary to describe the active NextWork page or the sidebar itself?
- Switch back to the completed NextWork project tab in Firefox.
- Click Capture current project in the extension sidebar.
You should see the project's page title, character count, and code-block count above the populated review area. This proves the click captured the active NextWork page.
- Review the text in the Reviewed project source area.
- Remove any tutorial navigation or unrelated rendered text that should not shape the future workspace.
- Identify one visible limitation in hierarchy, spacing, responsive styling, or content structure.
That is the baseline secured: your working project is visible, editable, and still local to the browser.
Sidebar not showing the capture?
Confirm that the completed project is the active tab when you click the capture button. The extension has page access for the official NextWork host.
If the text area remains empty, check the development terminal for a compile error and compare the three files above. Help me diagnose why the Firefox sidebar is not capturing my active NextWork page.
Your working baseline is now captured without being judged or changed. Next, you will give the refinement API clear instructions to preserve its behavior while applying only the improvements you request.
Request a Behavior-Preserving Improvement
Your Firefox sidebar now captures a working project only after you approve the action. The editable source gives you control over what leaves the browser.
The captured NextWork project already works. This step adds an API refinement flow that preserves its behavior while following your selected improvement mode and brief.
In this step, get ready to:
- Create a client that sends a behavior-preserving improvement request.
- Connect the improvement controls to the client and free demo.
- Preview a complete workspace without receiving a quality verdict.
Create the improvement client
The improvement client gives the sidebar one controlled route to an external service. Its TypeScript contract limits the request to the reviewed capture and the improvement choices you made.
Why preserve behavior?
The captured project is the functional baseline. The selected mode tells the API whether it may organize files, repair discovered bugs, polish the interface, or combine those changes.
The response contains a complete workspace and a change summary. It never contains a pass-or-fail judgment about the original project.
- In the VS Code Explorer sidebar, create lib/client.ts inside the existing lib folder.
- Add the response contract and endpoint validator by pasting this code into lib/client.ts:
import { browser } from "wxt/browser";
import type {
CapturedProject,
ImprovementMode,
WorkspaceResponse,
} from "./contracts";
const OUTPUT_CONTRACT = {
projectName: "Folder-safe project name",
summary: "Short explanation of how the workspace was improved",
changes: ["Concrete change"],
environment: ["ENVIRONMENT_VARIABLE_NAME"],
files: [{ path: "relative/path.ext", content: "complete file contents" }],
};
function endpointPattern(endpoint: string): string {
const url = new URL(endpoint);
if (url.protocol !== "https:") throw new Error("The endpoint must use HTTPS.");
if (url.username || url.password || url.port) {
throw new Error("Use standard HTTPS without embedded credentials or a custom port.");
}
return `https://${url.hostname}/*`;
}
What does this code do?
- OUTPUT_CONTRACT describes the complete multi-file response the sidebar expects.
- endpointPattern() accepts a standard HTTPS address without embedded credentials or a custom port.
- The returned origin pattern limits the permission request to the endpoint selected by the learner.
- Save lib/client.ts.
- Confirm that client.ts is listed beneath the lib folder in the Explorer sidebar.
Cannot find the new client file?
Check that client.ts sits directly inside lib. A file created beside the lib folder cannot use the existing relative contracts import.
Use this if the file path or import is still unclear: Help me place client.ts and resolve its contracts import.
requestImprovement() is one function with two internal stages. Add both code chunks below before saving the completed function.
- Add the permission and header stage directly below endpointPattern():
export async function requestImprovement(
endpoint: string,
token: string,
project: CapturedProject,
mode: ImprovementMode,
brief: string,
): Promise<WorkspaceResponse> {
const granted = await browser.permissions.request({
origins: [endpointPattern(endpoint)],
});
if (!granted) throw new Error("Permission to contact the improvement API was not granted.");
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token.trim()) headers.Authorization = `Bearer ${token.trim()}`;
How does permission stay limited?
browser.permissions.request() asks for access to the selected endpoint after the learner clicks the improvement button. A rejected permission stops the request before project content is transmitted.
The optional bearer token is added to the request headers only when the password field contains a value.
- Complete requestImprovement() by adding the request and response stage directly below the headers:
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
project,
improvement: { mode, brief },
rules: [
"Treat the supplied project as a functional baseline, not a pass-or-fail submission.",
"Preserve its core behavior and learning goal.",
"Fix bugs only when found and describe each fix in changes.",
"Improve visual design only when the mode requests it.",
"Return every source, style, asset reference, dependency file, and configuration file needed to continue locally.",
"Include a root package.json with install and run scripts plus a root README.md.",
"Return environment variable names only. Never return secret values.",
"Do not return node_modules, dist, build caches, or version-control history.",
],
outputContract: OUTPUT_CONTRACT,
}),
});
if (!response.ok) throw new Error(`The API returned HTTP ${response.status}.`);
const body = await response.text();
try {
return JSON.parse(body) as WorkspaceResponse;
} catch {
throw new Error("The API response was not JSON.");
}
}
What does the request contain?
- The request sends the reviewed CapturedProject with the selected mode and plain-language brief.
- The rules preserve the original behavior while allowing only the requested repair or presentation work.
- The output contract asks for project metadata, environment-variable names, and complete file contents.
- The response parser converts valid JSON into a WorkspaceResponse and reports an invalid response body.
- Save lib/client.ts.
- Confirm that the opening brace for requestImprovement() has a matching closing brace at the bottom of the file.
Seeing a TypeScript error in the client?
Check that the second request chunk sits inside requestImprovement(). Its first line begins after the headers from the preceding chunk.
Use the complete-file reference below to compare braces and imports. You can also ask for targeted help: Help me find the TypeScript mismatch in client.ts.
✔️ Awesome, I've got everything!
Your client now validates the endpoint before it requests permission or transmits the reviewed project.
ⓧ I'd like to double check the full code
import { browser } from "wxt/browser";
import type {
CapturedProject,
ImprovementMode,
WorkspaceResponse,
} from "./contracts";
const OUTPUT_CONTRACT = {
projectName: "Folder-safe project name",
summary: "Short explanation of how the workspace was improved",
changes: ["Concrete change"],
environment: ["ENVIRONMENT_VARIABLE_NAME"],
files: [{ path: "relative/path.ext", content: "complete file contents" }],
};
function endpointPattern(endpoint: string): string {
const url = new URL(endpoint);
if (url.protocol !== "https:") throw new Error("The endpoint must use HTTPS.");
if (url.username || url.password || url.port) {
throw new Error("Use standard HTTPS without embedded credentials or a custom port.");
}
return `https://${url.hostname}/*`;
}
export async function requestImprovement(
endpoint: string,
token: string,
project: CapturedProject,
mode: ImprovementMode,
brief: string,
): Promise<WorkspaceResponse> {
const granted = await browser.permissions.request({
origins: [endpointPattern(endpoint)],
});
if (!granted) throw new Error("Permission to contact the improvement API was not granted.");
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token.trim()) headers.Authorization = `Bearer ${token.trim()}`;
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
project,
improvement: { mode, brief },
rules: [
"Treat the supplied project as a functional baseline, not a pass-or-fail submission.",
"Preserve its core behavior and learning goal.",
"Fix bugs only when found and describe each fix in changes.",
"Improve visual design only when the mode requests it.",
"Return every source, style, asset reference, dependency file, and configuration file needed to continue locally.",
"Include a root package.json with install and run scripts plus a root README.md.",
"Return environment variable names only. Never return secret values.",
"Do not return node_modules, dist, build caches, or version-control history.",
],
outputContract: OUTPUT_CONTRACT,
}),
});
if (!response.ok) throw new Error(`The API returned HTTP ${response.status}.`);
const body = await response.text();
try {
return JSON.parse(body) as WorkspaceResponse;
} catch {
throw new Error("The API response was not JSON.");
}
}
How is the file organized?
The file defines the expected response shape first. It validates the chosen endpoint before it requests permission and posts the reviewed project.
Connect the improvement controls
The sidebar controls already collect the mode, brief, endpoint, optional token, and consent decision. The event handlers now turn those values into either a real refinement request or the built-in demo response.
- In entrypoints/sidepanel/main.ts, replace the import section at the top with this complete import section:
import { downloadWorkspace } from "../../lib/archive";
import { captureActiveProject } from "../../lib/capture";
import { requestImprovement } from "../../lib/client";
import {
POLISHED_DEMO,
type CapturedProject,
type ImprovementMode,
type PreparedWorkspace,
} from "../../lib/contracts";
import { prepareWorkspace } from "../../lib/workspace";
import "./style.css";
What do these imports add?
requestImprovement connects the approved form values to the new client. POLISHED_DEMO provides a complete local response for testing without an external endpoint.
ImprovementMode keeps the selected option aligned with preserve, repair, polish, or repair-polish.
- Save entrypoints/sidepanel/main.ts.
- Return to the sidebar in Firefox after the WXT development session reloads it.
You should still see the captured project and the status area. The new client import compiles without changing the existing interface.
Did the sidebar stop reloading?
Check that the client import uses ../../lib/client. Check that lib/client.ts is saved.
Use this if the import remains unresolved: Help me trace the client import from the sidepanel entrypoint.
- Add the Improve project handler below the existing capture and reviewed-text handlers:
improveButton.addEventListener("click", async () => {
clearWorkspace();
try {
if (!capturedProject) throw new Error("Capture a NextWork project first.");
if (!projectText.value.trim()) throw new Error("The reviewed project source is empty.");
if (!endpoint.value.trim()) throw new Error("Enter an HTTPS improvement endpoint.");
if (!brief.value.trim()) throw new Error("Describe the improvement you want.");
if (!consent.checked) throw new Error("Review and approve the transmission first.");
improveButton.disabled = true;
setStatus("Improving the project while preserving its core behavior...");
const result = await requestImprovement(
endpoint.value.trim(),
apiToken.value,
{ ...capturedProject, text: projectText.value.trim() },
mode.value as ImprovementMode,
brief.value.trim(),
);
apiToken.value = "";
showWorkspace(result);
} catch (error) {
apiToken.value = "";
setStatus(error instanceof Error ? error.message : "Improvement failed.");
} finally {
improveButton.disabled = false;
}
});
How does the handler protect the request?
- The guard checks require a local capture, reviewed source, endpoint, prompt, and consent before transmission.
- The handler rejects only an empty prompt. It passes the complete trimmed prompt to requestImprovement() without slicing or truncation.
- The selected mode and edited source are sent with the prompt.
- The token field is cleared after success or failure.
- The returned workspace is passed to showWorkspace() for a summary and file-tree preview.
- Save entrypoints/sidepanel/main.ts.
Before you test the handler, what do you expect it to do when the endpoint field is empty?
- Return to the Firefox sidebar after it reloads.
- Leave the HTTPS improvement endpoint field empty.
- Click Improve project.
You should see Enter an HTTPS improvement endpoint. in the status area. This proves the handler blocks an incomplete request before asking for permission or transmitting content.
Did the empty request continue?
Confirm that the endpoint check appears before the brief and consent checks inside the try block. Confirm that you saved entrypoints/sidepanel/main.ts before returning to Firefox.
Use this if the click does not update the status: Help me debug the Improve project event handler.
Check your provider's pricing and limits
The polished demo is free because it stays inside the extension. A learner-provided improvement endpoint may charge for input, output, or tool execution.
The extension sends the complete prompt without shortening it. Very long prompts may cost more or exceed the provider's request-size or context-window limits, so review that provider's pricing and limits before sending one.
- Add the polished demo handler directly below the Improve project handler:
demoButton.addEventListener("click", () => {
clearWorkspace();
showWorkspace(POLISHED_DEMO);
});
What does the demo handler do?
The handler clears any previous preview before passing POLISHED_DEMO to the same workspace display flow used by a real response. This keeps the free test representative of the external API path.
- Save entrypoints/sidepanel/main.ts.
✔️ Awesome, I've got everything!
The sidebar now handles guarded improvement requests and the built-in polished demo.
ⓧ I'd like to double check the full code
import { downloadWorkspace } from "../../lib/archive";
import { captureActiveProject } from "../../lib/capture";
import { requestImprovement } from "../../lib/client";
import {
POLISHED_DEMO,
type CapturedProject,
type ImprovementMode,
type PreparedWorkspace,
} from "../../lib/contracts";
import { prepareWorkspace } from "../../lib/workspace";
import "./style.css";
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing interface element: ${id}`);
return element as T;
}
const captureButton = byId<HTMLButtonElement>("capture-button");
const captureSummary = byId<HTMLParagraphElement>("capture-summary");
const projectText = byId<HTMLTextAreaElement>("project-text");
const mode = byId<HTMLSelectElement>("mode");
const brief = byId<HTMLTextAreaElement>("brief");
const endpoint = byId<HTMLInputElement>("endpoint");
const apiToken = byId<HTMLInputElement>("api-token");
const consent = byId<HTMLInputElement>("consent");
const improveButton = byId<HTMLButtonElement>("improve-button");
const demoButton = byId<HTMLButtonElement>("demo-button");
const improvementSummary = byId<HTMLParagraphElement>("improvement-summary");
const changeList = byId<HTMLUListElement>("change-list");
const fileTree = byId<HTMLUListElement>("file-tree");
const downloadButton = byId<HTMLButtonElement>("download-button");
const status = byId<HTMLParagraphElement>("status");
let capturedProject: CapturedProject | null = null;
let workspace: PreparedWorkspace | null = null;
function setStatus(message: string): void {
status.textContent = message;
}
function clearWorkspace(): void {
workspace = null;
downloadButton.disabled = true;
improvementSummary.textContent = "No improved workspace loaded.";
changeList.replaceChildren();
fileTree.replaceChildren();
}
function showWorkspace(value: unknown): void {
workspace = prepareWorkspace(value);
improvementSummary.textContent = workspace.summary;
changeList.replaceChildren();
fileTree.replaceChildren();
for (const change of workspace.changes) {
const item = document.createElement("li");
item.textContent = change;
changeList.append(item);
}
for (const file of workspace.files) {
const item = document.createElement("li");
item.textContent = `${workspace.rootName}/${file.path}`;
fileTree.append(item);
}
downloadButton.disabled = false;
setStatus("Finished workspace ready to download.");
}
captureButton.addEventListener("click", async () => {
clearWorkspace();
setStatus("Capturing the working NextWork project...");
try {
capturedProject = await captureActiveProject();
projectText.value = capturedProject.text;
captureSummary.textContent = `${capturedProject.title} | ${capturedProject.text.length.toLocaleString()} characters | ${capturedProject.codeBlocks.length} code block(s)`;
setStatus("Capture complete. Preserve the behavior and describe only the improvements you want.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Capture failed.");
}
});
projectText.addEventListener("input", () => {
if (capturedProject) capturedProject = { ...capturedProject, text: projectText.value };
});
improveButton.addEventListener("click", async () => {
clearWorkspace();
try {
if (!capturedProject) throw new Error("Capture a NextWork project first.");
if (!projectText.value.trim()) throw new Error("The reviewed project source is empty.");
if (!endpoint.value.trim()) throw new Error("Enter an HTTPS improvement endpoint.");
if (!brief.value.trim()) throw new Error("Describe the improvement you want.");
if (!consent.checked) throw new Error("Review and approve the transmission first.");
improveButton.disabled = true;
setStatus("Improving the project while preserving its core behavior...");
const result = await requestImprovement(
endpoint.value.trim(),
apiToken.value,
{ ...capturedProject, text: projectText.value.trim() },
mode.value as ImprovementMode,
brief.value.trim(),
);
apiToken.value = "";
showWorkspace(result);
} catch (error) {
apiToken.value = "";
setStatus(error instanceof Error ? error.message : "Improvement failed.");
} finally {
improveButton.disabled = false;
}
});
demoButton.addEventListener("click", () => {
clearWorkspace();
showWorkspace(POLISHED_DEMO);
});
downloadButton.addEventListener("click", async () => {
if (!workspace) return;
downloadButton.disabled = true;
setStatus("Creating the complete workspace ZIP locally...");
try {
await downloadWorkspace(workspace);
setStatus(`${workspace.rootName}.zip was created.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "ZIP creation failed.");
} finally {
downloadButton.disabled = workspace == null;
}
});
How is the sidebar flow organized?
The file keeps capture, reviewed-text edits, improvement requests, demo loading, workspace preview, and downloading in separate handlers. Both improvement paths use the same showWorkspace() function.
Preview the polished demo
POLISHED_DEMO follows the same WorkspaceResponse contract expected from a real endpoint. Its files form a complete Vite project while staying local to the extension.
Before you load it, which preview sections do you expect to change when a complete workspace response arrives?
- Return to the Firefox sidebar after the development extension reloads.
- Click Load polished demo.
You should see a behavior-preserving change summary and a populated file tree. The preview contains no valid, invalid, pass, or fail verdict.
What should the workspace contain?
- nextwork-polished-demo/package.json provides the dependency metadata and run scripts.
- nextwork-polished-demo/README.md explains the local workflow.
- nextwork-polished-demo/.gitignore excludes generated and private files.
- nextwork-polished-demo/index.html provides the application entry point.
- nextwork-polished-demo/src/main.js and nextwork-polished-demo/src/style.css contain the behavior and presentation.
That is the refinement loop working: one approved capture can produce a complete workspace preview without the extension executing returned source.
Is the demo preview still empty?
Confirm that the demo handler calls showWorkspace(POLISHED_DEMO). Confirm that POLISHED_DEMO is included in the contracts import.
Use this if the summary or file tree remains blank: Help me trace the polished demo into the workspace preview.
Your sidebar can now request a constrained refinement or load the free demo through the same response path. Next up, you will turn that response into a safe single-root workspace.
Assemble a Portable Workspace
Your sidebar can already turn a reviewed capture into a multi-file API response. The next challenge is making that response safe to package as one portable workspace.
The prepareWorkspace() function applies mechanical packaging rules before anything reaches the preview. Project quality remains outside the scope of these checks.
In this step, get ready to:
- Create validation boundaries for returned files and paths.
- Require the files that make the npm workspace runnable.
- Generate placeholders for declared environment variables and preview every file beneath one safe root.
Set the packaging rules
A returned file path can affect where the exported file lands. The assembler accepts safe relative paths within one project root.
The response also needs limits because every file stays in the sidebar until the ZIP is generated. Fixed boundaries keep that temporary workspace predictable.
What does the assembler protect?
- Shape checks confirm that the response contains the expected text fields and file entries.
- Path checks reject absolute paths and traversal segments.
- Size checks limit the number of files and the amount of content held in memory.
- Structural checks confirm that the exported root can support the promised npm workflow.
- Switch back to Visual Studio Code from earlier.
- Use the Explorer file controls to create workspace.ts inside the lib folder.
You should see workspace.ts listed beside capture.ts, client.ts, and contracts.ts in the lib folder.
- Select the full-code tab below.
- Copy the complete lib/workspace.ts file.
- Paste the copied code into lib/workspace.ts.
- Save lib/workspace.ts.
✔️ Awesome, I've got everything!
Your workspace preparation layer is complete. Keep this tab selected if your file matches the reference.
ⓧ I'd like to double check the full code
import type {
PreparedWorkspace,
WorkspaceFile,
WorkspaceResponse,
} from "./contracts";
const MAX_FILES = 300;
const MAX_PATH_LENGTH = 240;
const MAX_FILE_CHARACTERS = 750_000;
const MAX_TOTAL_CHARACTERS = 8_000_000;
const GENERATED_ROOTS = new Set(["node_modules", "dist", "build", ".git", ".cache", ".wxt"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function preparePath(path: string): string {
const clean = path.trim();
if (!clean) throw new Error("A workspace file has no path.");
if (clean.length > MAX_PATH_LENGTH) throw new Error(`Path is too long: ${clean}`);
if (clean.startsWith("/") || /^[A-Za-z]:/.test(clean) || clean.includes("\\")) {
throw new Error(`Path cannot be packaged safely: ${clean}`);
}
const parts = clean.split("/");
if (parts.some((part) => part === "" || part === "." || part === "..")) {
throw new Error(`Path cannot be packaged safely: ${clean}`);
}
if (GENERATED_ROOTS.has(parts[0] ?? "")) {
throw new Error(`Generated or private folder must not be exported: ${clean}`);
}
return clean;
}
function rootName(name: string): string {
const clean = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[.-]+|[.-]+$/g, "");
if (!clean) throw new Error("The workspace needs a project name.");
return clean;
}
export function prepareWorkspace(value: unknown): PreparedWorkspace {
if (!isRecord(value)) throw new Error("The API did not return a workspace object.");
if (typeof value.projectName !== "string") throw new Error("The workspace needs projectName.");
if (typeof value.summary !== "string") throw new Error("The workspace needs a summary.");
if (!Array.isArray(value.changes) || !value.changes.every((item) => typeof item === "string")) {
throw new Error("The workspace needs a text changes list.");
}
if (!Array.isArray(value.environment) || !value.environment.every((item) => typeof item === "string")) {
throw new Error("The workspace environment list must contain names only.");
}
if (!Array.isArray(value.files) || value.files.length === 0 || value.files.length > MAX_FILES) {
throw new Error("The workspace file list is empty or too large.");
}
const seen = new Set<string>();
const files: WorkspaceFile[] = [];
let totalCharacters = 0;
for (const item of value.files) {
if (!isRecord(item) || typeof item.path !== "string" || typeof item.content !== "string") {
throw new Error("Each workspace file needs path and content text.");
}
const path = preparePath(item.path);
if (seen.has(path)) throw new Error(`The workspace repeats this path: ${path}`);
if (item.content.length > MAX_FILE_CHARACTERS) throw new Error(`File is too large: ${path}`);
seen.add(path);
totalCharacters += item.content.length;
files.push({ path, content: item.content });
}
if (totalCharacters > MAX_TOTAL_CHARACTERS) throw new Error("The workspace is too large to package.");
if (!seen.has("package.json")) throw new Error("A portable npm workspace needs package.json.");
if (!seen.has("README.md")) throw new Error("A portable workspace needs README.md.");
const packageFile = files.find((file) => file.path === "package.json");
let packageData: unknown;
try {
packageData = JSON.parse(packageFile?.content ?? "");
} catch {
throw new Error("package.json must be parseable JSON.");
}
if (!isRecord(packageData) || !isRecord(packageData.scripts) || typeof packageData.scripts.dev !== "string") {
throw new Error("package.json needs a dev script for npm run dev.");
}
const environment = Array.from(
new Set(
value.environment.filter((name): name is string =>
typeof name === "string" && /^[A-Z][A-Z0-9_]*$/.test(name),
),
),
);
if (environment.length > 0 && !seen.has(".env.example")) {
files.push({
path: ".env.example",
content: `${environment.map((name) => `${name}=`).join("\n")}\n`,
});
}
return {
projectName: value.projectName,
rootName: rootName(value.projectName),
summary: value.summary,
changes: value.changes,
environment,
files,
} as PreparedWorkspace;
}
How does this file work?
- The four constants define the maximum file count and content sizes accepted by the sidebar.
- The preparePath() helper rejects paths that could escape the exported project root.
- The GENERATED_ROOTS set keeps dependency folders and build output out of the workspace.
- The rootName() helper converts the returned project name into a folder-safe root.
- The prepareWorkspace() function applies every check before returning a PreparedWorkspace.
- Switch back to the terminal panel from earlier.
- Confirm the development build completes without reporting a TypeScript problem.
That is the safety boundary in place. Your extension can now reject malformed workspace data before it reaches the preview.
Seeing a TypeScript problem?
Check that workspace.ts sits directly inside the lib folder. Confirm that the type import still points to ./contracts.
Compare the final closing braces with the full-file reference. A missing brace can make the rest of the file appear invalid.
Ask for help with the exact compiler output: Help me diagnose the TypeScript problem in my workspace assembler.
Trace the portable workspace contract
A portable npm workspace needs a root package.json that contains parseable JSON. It also needs a dev script plus a root README.md.
These requirements confirm that the response has the promised export shape. The checks make no claim about the original project's design or correctness.
- In lib/workspace.ts, locate the structural checks shown below.
if (totalCharacters > MAX_TOTAL_CHARACTERS) throw new Error("The workspace is too large to package.");
if (!seen.has("package.json")) throw new Error("A portable npm workspace needs package.json.");
if (!seen.has("README.md")) throw new Error("A portable workspace needs README.md.");
const packageFile = files.find((file) => file.path === "package.json");
let packageData: unknown;
try {
packageData = JSON.parse(packageFile?.content ?? "");
} catch {
throw new Error("package.json must be parseable JSON.");
}
if (!isRecord(packageData) || !isRecord(packageData.scripts) || typeof packageData.scripts.dev !== "string") {
throw new Error("package.json needs a dev script for npm run dev.");
}
What do these checks prove?
- The total-character check prevents an oversized response from remaining in sidebar memory.
- The file checks require package.json and README.md at the workspace root.
- The parsing block confirms that package.json contains valid JSON.
- The final condition confirms that scripts.dev is a text command.
Preview the assembled workspace
The final stage keeps valid environment-variable names from the response. It creates .env.example when the API declares names without supplying that file.
The prepared result also carries a sanitized rootName. The sidebar uses that value as the prefix for every previewed path.
- In lib/workspace.ts, review the environment and return section shown below.
const environment = Array.from(
new Set(
value.environment.filter((name): name is string =>
typeof name === "string" && /^[A-Z][A-Z0-9_]*$/.test(name),
),
),
);
if (environment.length > 0 && !seen.has(".env.example")) {
files.push({
path: ".env.example",
content: `${environment.map((name) => `${name}=`).join("\n")}\n`,
});
}
return {
projectName: value.projectName,
rootName: rootName(value.projectName),
summary: value.summary,
changes: value.changes,
environment,
files,
} as PreparedWorkspace;
}
How is the final workspace prepared?
- The filter accepts uppercase environment-variable names that match the expected naming pattern.
- The set removes repeated environment-variable names.
- The generated .env.example contains empty placeholders instead of secret values.
- The returned object preserves the response summary and change list for the sidebar preview.
- The returned files array keeps the source tree ready for root-prefixed rendering.
Before you load the demo, which root folder and structural files do you expect the preview to show?
- Switch back to Firefox with the development sidebar from earlier.
- Click Load polished demo in the improvement panel.
You'll see nextwork-polished-demo/ containing package.json, README.md, .gitignore, index.html, and the src/ directory from the polished Vite demo.
You'll also see the change summary above the root-prefixed file tree. The Download workspace button is now enabled.
That packaging loop now works from response to preview. Every accepted file has a safe place beneath one project root.
Is the download button still disabled?
Confirm that you clicked Load polished demo and that the status area does not report a workspace preparation problem.
Check that your prepareWorkspace() return object includes rootName and files.
Ask for help with the status message: Help me find why the prepared workspace is not enabling the download button.
Your API response now becomes a bounded single-root workspace with a visible file tree. Next up, you'll package those prepared files into a downloadable ZIP and run the finished demo locally.
Download and Run the Workspace
The sidebar already turns a response into a safe single-root preview. You can now see exactly which files are ready to leave the extension.
A preview still leaves the finished app inside the exporter. This step packages that workspace as a ZIP so you can run it locally or move it to another builder.
In this step, get ready to:
- Package every prepared file beneath one project root inside a ZIP.
- Run the polished demo workspace locally.
- Produce the extension builds for each browser target.
Package the prepared workspace
The JSZip library builds an archive entirely inside the sidebar. The exporter writes returned code as files without executing it.
- In the Visual Studio Code Explorer sidebar, select the existing lib folder.
- Create archive.ts inside the lib folder.
- Add the archive helper by pasting this code into lib/archive.ts:
import JSZip from "jszip";
import type { PreparedWorkspace } from "./contracts";
export async function downloadWorkspace(workspace: PreparedWorkspace): Promise<void> {
const zip = new JSZip();
for (const file of workspace.files) {
zip.file(`${workspace.rootName}/${file.path}`, file.content);
}
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${workspace.rootName}.zip`;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
What Does This Code Do?
- The for loop places every prepared file beneath workspace.rootName.
- The generateAsync() call creates a compressed Blob using DEFLATE compression.
- The temporary link starts the local file download.
- The timer releases the temporary object URL after the download begins.
- Save lib/archive.ts.
- Confirm that archive.ts now appears inside the lib folder in the Explorer sidebar.
Archive File Not Appearing?
Check that archive.ts sits directly inside lib. Make sure the editor tab no longer shows an unsaved-change indicator.
Help me check why my archive helper is missing from the project.
✔️ Awesome, I've got everything!
Your archive helper is saved inside lib.
ⓧ I'd like to double check the full code
import JSZip from "jszip";
import type { PreparedWorkspace } from "./contracts";
export async function downloadWorkspace(workspace: PreparedWorkspace): Promise<void> {
const zip = new JSZip();
for (const file of workspace.files) {
zip.file(`${workspace.rootName}/${file.path}`, file.content);
}
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${workspace.rootName}.zip`;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
The sidebar already stores the prepared workspace in workspace. The download button now needs to pass that state into downloadWorkspace().
- In entrypoints/sidepanel/main.ts, find this import:
import { captureActiveProject } from "../../lib/capture";
- Place the archive import directly above the capture import so those lines read:
import { downloadWorkspace } from "../../lib/archive";
import { captureActiveProject } from "../../lib/capture";
The click handler protects the button while the archive is being generated. Its status messages make the download result visible inside the sidebar.
- At the bottom of entrypoints/sidepanel/main.ts, add the download handler by pasting this code:
downloadButton.addEventListener("click", async () => {
if (!workspace) return;
downloadButton.disabled = true;
setStatus("Creating the complete workspace ZIP locally...");
try {
await downloadWorkspace(workspace);
setStatus(`${workspace.rootName}.zip was created.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "ZIP creation failed.");
} finally {
downloadButton.disabled = workspace == null;
}
});
How Does the Download Handler Work?
- The guard exits when no prepared workspace exists.
- The button stays disabled while the ZIP is being created.
- The try block downloads the archive through downloadWorkspace().
- The finally block restores the correct button state after either outcome.
- Save entrypoints/sidepanel/main.ts.
- Return to the exporter sidebar in Firefox.
- Click Load polished demo.
Before you download it, predict which root name the ZIP filename will use.
- Click Download workspace.
You should receive nextwork-polished-demo.zip. The sidebar status should report that the ZIP was created.
ZIP Not Downloading?
Load the polished demo again if Download workspace is disabled. Check that the new archive import points to ../../lib/archive.
Help me debug why the workspace ZIP is not downloading.
✔️ Awesome, I've got everything!
Your sidebar can now package the prepared workspace without executing its returned code.
ⓧ I'd like to double check the full code
import { downloadWorkspace } from "../../lib/archive";
import { captureActiveProject } from "../../lib/capture";
import { requestImprovement } from "../../lib/client";
import {
POLISHED_DEMO,
type CapturedProject,
type ImprovementMode,
type PreparedWorkspace,
} from "../../lib/contracts";
import { prepareWorkspace } from "../../lib/workspace";
import "./style.css";
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing interface element: ${id}`);
return element as T;
}
const captureButton = byId<HTMLButtonElement>("capture-button");
const captureSummary = byId<HTMLParagraphElement>("capture-summary");
const projectText = byId<HTMLTextAreaElement>("project-text");
const mode = byId<HTMLSelectElement>("mode");
const brief = byId<HTMLTextAreaElement>("brief");
const endpoint = byId<HTMLInputElement>("endpoint");
const apiToken = byId<HTMLInputElement>("api-token");
const consent = byId<HTMLInputElement>("consent");
const improveButton = byId<HTMLButtonElement>("improve-button");
const demoButton = byId<HTMLButtonElement>("demo-button");
const improvementSummary = byId<HTMLParagraphElement>("improvement-summary");
const changeList = byId<HTMLUListElement>("change-list");
const fileTree = byId<HTMLUListElement>("file-tree");
const downloadButton = byId<HTMLButtonElement>("download-button");
const status = byId<HTMLParagraphElement>("status");
let capturedProject: CapturedProject | null = null;
let workspace: PreparedWorkspace | null = null;
function setStatus(message: string): void {
status.textContent = message;
}
function clearWorkspace(): void {
workspace = null;
downloadButton.disabled = true;
improvementSummary.textContent = "No improved workspace loaded.";
changeList.replaceChildren();
fileTree.replaceChildren();
}
function showWorkspace(value: unknown): void {
workspace = prepareWorkspace(value);
improvementSummary.textContent = workspace.summary;
changeList.replaceChildren();
fileTree.replaceChildren();
for (const change of workspace.changes) {
const item = document.createElement("li");
item.textContent = change;
changeList.append(item);
}
for (const file of workspace.files) {
const item = document.createElement("li");
item.textContent = `${workspace.rootName}/${file.path}`;
fileTree.append(item);
}
downloadButton.disabled = false;
setStatus("Finished workspace ready to download.");
}
captureButton.addEventListener("click", async () => {
clearWorkspace();
setStatus("Capturing the working NextWork project...");
try {
capturedProject = await captureActiveProject();
projectText.value = capturedProject.text;
captureSummary.textContent = `${capturedProject.title} | ${capturedProject.text.length.toLocaleString()} characters | ${capturedProject.codeBlocks.length} code block(s)`;
setStatus("Capture complete. Preserve the behavior and describe only the improvements you want.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Capture failed.");
}
});
projectText.addEventListener("input", () => {
if (capturedProject) capturedProject = { ...capturedProject, text: projectText.value };
});
improveButton.addEventListener("click", async () => {
clearWorkspace();
try {
if (!capturedProject) throw new Error("Capture a NextWork project first.");
if (!projectText.value.trim()) throw new Error("The reviewed project source is empty.");
if (!endpoint.value.trim()) throw new Error("Enter an HTTPS improvement endpoint.");
if (!brief.value.trim()) throw new Error("Describe the improvement you want.");
if (!consent.checked) throw new Error("Review and approve the transmission first.");
improveButton.disabled = true;
setStatus("Improving the project while preserving its core behavior...");
const result = await requestImprovement(
endpoint.value.trim(),
apiToken.value,
{ ...capturedProject, text: projectText.value.trim() },
mode.value as ImprovementMode,
brief.value.trim(),
);
apiToken.value = "";
showWorkspace(result);
} catch (error) {
apiToken.value = "";
setStatus(error instanceof Error ? error.message : "Improvement failed.");
} finally {
improveButton.disabled = false;
}
});
demoButton.addEventListener("click", () => {
clearWorkspace();
showWorkspace(POLISHED_DEMO);
});
downloadButton.addEventListener("click", async () => {
if (!workspace) return;
downloadButton.disabled = true;
setStatus("Creating the complete workspace ZIP locally...");
try {
await downloadWorkspace(workspace);
setStatus(`${workspace.rootName}.zip was created.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "ZIP creation failed.");
} finally {
downloadButton.disabled = workspace == null;
}
});
Run the exported Vite app
The polished demo is a Vite workspace with its own source tree. Running it outside the extension proves that the downloaded ZIP contains a usable app.
- In Finder, double-click nextwork-polished-demo.zip to extract it.
- Keep the exporter window open in Visual Studio Code.
- Select File from the Visual Studio Code menu bar.
- Select New Window.
- In the new window, select File.
- Select Open Folder....
- Choose the extracted nextwork-polished-demo folder.
- Confirm the folder choice after checking that it is the built-in demo workspace.
The Explorer sidebar should show package.json, README.md, .gitignore, index.html, src/main.js, and src/style.css beneath one root.
- Select View from the Visual Studio Code menu bar.
- Select Terminal.
- Install the exported workspace dependencies by running:
npm install
You should see the installation complete without a dependency error. The terminal remains rooted in nextwork-polished-demo.
Dependency Installation Failing?
Check that the Visual Studio Code Explorer shows the extracted demo root. Confirm that its package.json file is visible before retrying the installation.
Help me debug the polished demo dependency installation.
Before you start the development server, picture the interface you expect this exported source tree to render.
- Start the polished demo development server by running:
npm run dev
You should see a local URL in the terminal. The server keeps using this terminal while the demo is running.
- Open the local URL shown in the terminal.
- Click Try the interaction in the demo app.
You should see the button change to Interaction works. That proves the exported source still has working behavior.
Demo Not Loading?
Keep the development command running while you open its local URL. Check the terminal for a source filename if the page reports a build problem.
Help me debug why the exported Vite demo is not loading.
- Select Terminal from the Visual Studio Code menu bar.
- Select New Terminal.
Before you build the app, predict which new folder should contain its production output.
- Create the production build from the exported workspace root by running:
npm run build
You should see the production build complete. A dist folder should now appear in the Explorer sidebar.
Production Build Failing?
Confirm that the new terminal is rooted in nextwork-polished-demo. Check that the dependency installation completed before the build command.
Help me fix the exported workspace production build.
Why Is This ZIP Portable?
The archive keeps one clear project root with its source tree. It also includes dependency metadata plus run instructions.
That structure is ready for local development or Replit ZIP import. Dependencies can be installed again from package.json.
Build every extension target
The exported app is now proven outside the sidebar. The final check uses the existing WXT scripts to produce separate extension artifacts from the same codebase.
- Switch back to the Visual Studio Code window containing nextwork-app-exporter.
- Select Terminal from the Visual Studio Code menu bar.
- Select New Terminal.
Before you run the target builds, predict whether the same extension source can produce three browser-specific outputs.
- Build the Firefox extension target by running:
npm run build:firefox
You should see the Firefox build complete. Its generated target folder should appear inside .output.
- Build the Chromium extension target by running:
npm run build:chromium
You should see the Chromium build complete. Another target folder should now appear inside .output.
- Build the Safari extension target by running:
npm run build:safari
You should see the Safari build complete. The .output folder should now contain generated artifacts for all three targets.
Browser Build Not Completing?
Confirm that the terminal is rooted in nextwork-app-exporter. Read the first TypeScript filename reported by the failed target build.
Help me debug a failed WXT browser target build.
What Does the Safari Build Include?
The Safari target produces compatible web-extension files. Apple still requires a separate native app wrapper for conversion plus store packaging.
That wrapper workflow stays outside this project. Your source compatibility build is complete.
You have completed the full export loop. The sidebar packages a prepared workspace locally without executing returned code.
That is the final proof: the demo ZIP installs and runs as an app. Your extension also builds for each planned browser target.
Secret mission
Re-export with a UI Polish Brief
Use the same captured project to create two controlled exports. You will compare a behavior-preserving workspace with a second workspace shaped by a focused visual-design brief.
Clean Up Your Resources
Clean Up Your Resources
These local resources create no ongoing charges. Decide whether to keep them available, pause the development session, or delete the project copies permanently.
Resources you used:
- The active WXT development process.
- The Firefox development window with the exporter sidebar.
- The nextwork-app-exporter folder on your Desktop.
- The Preserve mode ZIP file plus its extracted single-root workspace.
- The Polish UI mode ZIP file plus its extracted single-root workspace.
Keep everything running
No action is needed. Choose this option if you are still testing the extension or comparing its exported workspaces.
- Leave the WXT development process running.
- Keep the Firefox development window open.
- Keep the nextwork-app-exporter folder on your Desktop.
- Keep both comparison ZIP files.
- Keep both extracted workspaces.
Pause - I'll come back to this later
Stop the running development process to free local memory. Your source files plus comparison exports remain ready for another session.
- Return to the terminal panel running the WXT development process.
- Stop the development process.
- Close the Firefox development window.
- Keep the nextwork-app-exporter folder on your Desktop.
- Keep both comparison ZIP files.
- Keep both extracted workspaces.
That is a clean pause. Your source files plus exports remain available for the next session.
Delete - I don't want to use this again
Deletion is permanent. Your original functional NextWork project remains untouched because the exporter uses an independent codebase.
- Return to the terminal panel running the WXT development process.
- Stop the development process.
- Close the Firefox development window.
- Delete the nextwork-app-exporter folder from your Desktop by running this command:
rm -rf ~/Desktop/nextwork-app-exporter
What Does This Command Remove?
The command removes the complete local extension project from your Desktop. Its target includes the source files, installed packages, plus extension build artifacts.
- Search your Desktop for nextwork-app-exporter using your Mac's file manager.
- Confirm the folder no longer appears on your Desktop.
Still See the Exporter Folder?
Check that the remaining folder name matches nextwork-app-exporter exactly. Confirm that you searched the Desktop location.
Help me remove the extension folder safely.
The extension source plus its build artifacts are now removed. The two comparison exports are the remaining project copies.
- Locate the Preserve mode ZIP file in the location where you saved it.
- Permanently delete the Preserve mode ZIP file using your Mac's file manager.
- Locate the extracted Preserve mode single-root workspace.
- Permanently delete the extracted Preserve mode workspace using your Mac's file manager.
- Locate the Polish UI mode ZIP file in the location where you saved it.
- Permanently delete the Polish UI mode ZIP file using your Mac's file manager.
- Locate the extracted Polish UI mode single-root workspace.
- Permanently delete the extracted Polish UI mode workspace using your Mac's file manager.
- Search the location where you saved the Preserve mode files.
- Confirm the Preserve mode ZIP file no longer appears.
- Confirm the extracted Preserve mode workspace no longer appears.
- Search the location where you saved the Polish UI mode files.
- Confirm the Polish UI mode ZIP file no longer appears.
- Confirm the extracted Polish UI mode workspace no longer appears.
Cleanup complete. The local exporter copies are gone while your original NextWork project remains available.
Nice Work!
Nice Work!
You made it. Your Firefox-first NextWork App Exporter now turns a working project into a portable developer workspace.
You've learned how to:
- Built an independent WXT extension with Firefox as its default development target. Added user-triggered capture for rendered text plus visible code blocks. Kept captured material editable before external transmission.
- Created a user-approved HTTPS API refinement workflow that protects the functional baseline. Supported preservation, bug repair, UI polish, plus combined refinement. Required complete files plus a change summary from every response.
- Converted multi-file responses into a safe single-root workspace. Downloaded the result as a ZIP with JSZip. Installed the exported Vite demo locally. Ran its development server. Produced its production build. Produced browser-targeted extension artifacts for Firefox, Chromium-family browsers, plus Safari.
- Secret Mission: Exported the same capture in Preserve mode. Re-exported it in Polish UI mode with a constrained design brief. Compared both change summaries. Compared both workspace trees. Confirmed the core behavior stayed unchanged. Confirmed the polished export reflected the requested presentation improvements.
Ready to quiz yourself?