Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/notes-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ A browser note-taking app served **with no bundler** — it dogfoods the
[`@statewalker/webrun-modules-build`](../../../webrun-files/packages/webrun-modules-build)
no-bundle pipeline end to end, and validates its Phase-3 CSS features in a real browser.

`newProjectBuild({ project, cache })` scans the TS + CSS sources under `src/` and
emits a **static `.js` tree** into `dist/` (ext-map: `main.ts` → `/~/main.js`,
`newProjectBuild({ project, cache })` scans the TS/TSX + CSS sources under `src/`
and emits a **static `.js` tree** into `dist/` (ext-map: `main.tsx` → `/~/main.js`,
`styles.css` → `/~/styles.js` `<style>`-injector, npm deps → transformed files at
the tree root). The browser loads `/~/main.js` as a plain ES module — **no bundler,
no CDN, no import map**. The UI is plain DOM/TypeScript (no framework), so the demo
showcases the pipeline and its CSS handling, not a UI library.
no CDN, no import map**. The UI is **React** (`react`/`react-dom` served from npm,
transformed CJS→ESM on the fly), so the demo also exercises no-bundle React
end-to-end (createRoot, hooks, state).

> No-bundle React needs two webrun-modules transform behaviours the CSS-only demo
> didn't: the JSX runtime must match the globals' `NODE_ENV` (browser → production
> `jsx`), and React's `process.env.NODE_ENV`-gated package entry must be dead-code-
> eliminated to a **single** react instance (else react-dom's `ReactSharedInternals`
> is undefined and it crashes at render). Both landed in webrun-modules.

## What it proves (Phase-3 features, verified in-browser)

Expand Down
8 changes: 6 additions & 2 deletions apps/notes-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Browser note-taking demo served no-bundle through @statewalker/webrun-modules-build: Tailwind (v4) styling, a plain @import CSS chain (F2 runtime) + url() asset (F3), and a swappable FilesApi store (persistent by default, in-memory via ?mem for tests).",
"description": "Browser React note-taking demo served no-bundle through @statewalker/webrun-modules-build: Tailwind (v4) styling, a plain @import CSS chain (F2 runtime) + url() asset (F3), React served from npm, and a swappable FilesApi store (persistent by default, in-memory via ?mem for tests).",
"license": "MIT",
"scripts": {
"build": "tsx build.ts",
Expand All @@ -15,10 +15,14 @@
"@statewalker/webrun-files": "catalog:",
"@statewalker/webrun-files-node": "catalog:",
"@statewalker/webrun-modules": "catalog:",
"@statewalker/webrun-modules-build": "catalog:"
"@statewalker/webrun-modules-build": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@statewalker/webrun-files-mem": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
Expand Down
96 changes: 0 additions & 96 deletions apps/notes-demo/src/app.ts

This file was deleted.

122 changes: 122 additions & 0 deletions apps/notes-demo/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from "react";
import type { NoteStore, Note } from "./store.js";

/** Stable id for a fresh note (no crypto dependency needed). */
const newId = () => `n-${Date.now().toString(36)}`;

export function App({ store, onChange }: { store: NoteStore; onChange?: () => void }) {
const [notes, setNotes] = useState<Note[]>([]);
const [id, setId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");

const reload = useCallback(async () => {
setNotes(await store.listNotes());
}, [store]);

useEffect(() => {
void reload();
}, [reload]);

const edit = (note: Note) => {
setId(note.id);
setTitle(note.title);
setBody(note.body);
};

const blank = () => {
setId(null);
setTitle("");
setBody("");
};

const save = async () => {
const note: Note = { id: id ?? newId(), title: title.trim() || "Untitled", body };
await store.saveNote(note);
blank();
await reload();
onChange?.();
};

const remove = async (target: string) => {
await store.deleteNote(target);
if (target === id) blank();
await reload();
onChange?.();
};

return (
<div className="flex h-screen gap-4 p-4 text-slate-800">
<aside className="flex w-64 flex-col gap-2">
<header className="flex items-center gap-2">
<span className="logo" aria-hidden="true" />
<h1 className="text-lg font-semibold text-brand">Notes</h1>
</header>
<button
type="button"
onClick={blank}
className="rounded bg-brand px-3 py-2 text-left text-sm font-medium text-white hover:opacity-90"
>
+ New note
</button>
<ul className="flex flex-col gap-1 overflow-y-auto">
{notes.map((note) => (
<li key={note.id} className="note-card flex items-center gap-2 rounded">
<button
type="button"
onClick={() => edit(note)}
className={`flex-1 truncate rounded px-2 py-1 text-left text-sm hover:bg-slate-100 ${
note.id === id ? "bg-slate-100 font-medium" : ""
}`}
>
{note.title}
</button>
<button
type="button"
onClick={() => remove(note.id)}
aria-label={`Delete ${note.title}`}
className="rounded px-2 py-1 text-sm text-slate-400 hover:text-red-600"
>
×
</button>
</li>
))}
{notes.length === 0 && (
<li className="px-2 py-1 text-sm text-slate-400">No notes yet.</li>
)}
</ul>
</aside>

<section className="flex flex-1 flex-col gap-3 rounded border border-slate-200 p-4">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
className="rounded border border-slate-200 px-3 py-2 text-lg font-medium outline-none focus:border-brand"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write something…"
className="flex-1 resize-none rounded border border-slate-200 p-3 outline-none focus:border-brand"
/>
<div className="flex gap-2">
<button
type="button"
onClick={save}
className="rounded bg-brand px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
{id ? "Update" : "Save"}
</button>
<button
type="button"
onClick={blank}
className="rounded border border-slate-200 px-4 py-2 text-sm hover:bg-slate-100"
>
Clear
</button>
</div>
</section>
</div>
);
}
21 changes: 12 additions & 9 deletions apps/notes-demo/src/main.ts → apps/notes-demo/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import { createRoot } from "react-dom/client";
import type { FilesApi } from "@statewalker/webrun-files";
import { readText, writeText } from "@statewalker/webrun-files";
import { MemFilesApi } from "@statewalker/webrun-files-mem";
import { mountApp } from "./app.js";
import { App } from "./app.js";
import { makeStore } from "./store.js";
// The Tailwind entry (/~/styles.js <style> injector). Its `@import "./tokens.css"`
// is INLINED by the Tailwind transform (D1 source-honoring), and the inlined
// `url("./logo.svg")` (F3) resolves against the document base — see <base href="/~/">.
import "./styles.css";
// A PLAIN CSS @import chain (theme.css → @import "./palette.css"). A plain @import
// is KEPT in the injector, so this is the case that exercises F2 at RUNTIME: the
// injected `@import "./palette.css"` resolves against the document base to the real
// is KEPT in the injector, so this exercises F2 at RUNTIME: the injected
// `@import "./palette.css"` resolves against the document base to the real
// /~/palette.css the build emits.
import "./theme.css";

const KEY = "notes-demo/snapshot";

/**
* Pick the store's FilesApi from the URL:
* default → a MemFilesApi hydrated from (and snapshotted back to) localStorage
* a real, swappable persistent store using only the mem impl.
* ?mem → a fresh in-memory MemFilesApi (no persistence) so the app is
* auto-testable from a clean slate.
* default → a MemFilesApi hydrated from (and snapshotted back to) localStorage
* a real, swappable persistent store using only the mem impl.
* ?mem → a fresh in-memory MemFilesApi (no persistence) so the app is
* auto-testable from a clean slate.
* Any other FilesApi (BrowserFilesApi/OPFS, NodeFilesApi) drops in unchanged.
*/
async function pickFiles(): Promise<{ files: FilesApi; onChange?: () => void }> {
if (new URLSearchParams(location.search).has("mem")) return { files: new MemFilesApi() };
if (new URLSearchParams(location.search).has("mem")) {
return { files: new MemFilesApi() };
}
const files = new MemFilesApi();
await hydrate(files);
return { files, onChange: () => void snapshot(files) };
Expand All @@ -51,4 +54,4 @@ async function snapshot(files: FilesApi): Promise<void> {
const root = document.getElementById("root");
if (!root) throw new Error("missing #root");
const { files, onChange } = await pickFiles();
mountApp(root, makeStore(files), onChange);
createRoot(root).render(<App store={makeStore(files)} onChange={onChange} />);
1 change: 1 addition & 0 deletions apps/notes-demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
Expand Down
Loading