diff --git a/.gitattributes b/.gitattributes
index 513eca2..70a0d19 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -32,3 +32,10 @@
/resources/js/**/*.test.ts export-ignore
/resources/js/**/*.test.tsx export-ignore
/resources/js/test export-ignore
+
+# The browser's Playwright suite (e2e/browser, with its baselines and harness pages) and the host
+# e2e check, and the two guard configs that came with them (react consolidation 3.1/3.3), are
+# development-only.
+/e2e export-ignore
+/.dependency-cruiser.cjs export-ignore
+/tsconfig.domain.json export-ignore
diff --git a/.github/workflows/committed-build.yml b/.github/workflows/committed-build.yml
index 2b51563..afd15f0 100644
--- a/.github/workflows/committed-build.yml
+++ b/.github/workflows/committed-build.yml
@@ -47,8 +47,10 @@ jobs:
- name: Compare with the committed bundle
run: |
- if ! git diff --exit-code --stat public/assets; then
- echo "::error title=Committed bundle is stale::public/assets differs from a fresh build of this tree. Run 'npm install --no-package-lock && npm run build:prod' and commit the changes under public/assets."
+ # build:prod also writes resources/views/prepaint.html (react consolidation 4.1), which the
+ # prepaint component inlines, so it is compared with the bundle.
+ if ! git diff --exit-code --stat public/assets resources/views/prepaint.html; then
+ echo "::error title=Committed bundle is stale::public/assets or resources/views/prepaint.html differs from a fresh build of this tree. Run 'npm install --no-package-lock && npm run build:prod' and commit the changes."
exit 1
fi
if [ -n "$(git status --porcelain --untracked-files=all public/assets)" ]; then
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51cdd5d..b246b69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,11 +6,15 @@ All notable changes to `ichava/icon-browser` follow [Keep a Changelog](https://k
### Added
+- **[Work on the browser UI](docs/recipes/frontend-development.md)**, a recipe for the dev server with hot reload, the checks, the Playwright suite and its baselines, and the build.
- **The browser's Playwright suite and guards run here** (react consolidation 2.6, 3.1 and 3.3). react-icon-browser's 20 e2e specs and their harness pages moved to `e2e/browser/`, built from `resources/js` by `e2e/browser/vite.config.ts` and run by `npm run e2e:browser` (98 tests, as in react-icon-browser); `npm run visual` compares the 12 committed Linux baselines inside the pinned Playwright image. `.github/workflows/browser.yml` runs the guards and the e2e suite on pull requests and the visual suite weekly and by dispatch; `visual-baselines.yml` seeds baselines on a pull request's branch. New npm scripts: `typecheck:domain`, `typecheck:e2e`, `boundaries`, `check:boundary-rules`, `check:schema-types`, `schema:types`, `check:dead-classes`, `check:dead-links`, `check:playwright-image`, `visual`, `visual:update`, `visual:linux`, `visual:linux:check`, `e2e:linux`.
- **The SVG rendering sample is tracked, with the public packs only** (tabler and flag, react-icon-browser's own 18 files). The svg-baseline spec no longer renders the private bundled and Metronic packs, so it covers six archetypes, not eight; `noPrivateContent.test.ts` fails if the sample holds anything else.
### Changed
+- **`npm run dev` serves the browser as it ships** (react consolidation 4.1). The dev server scopes the browser's stylesheet to its root and serves its fonts, as the build always did; before, a page in dev carried the browser's tokens on `:root` and its rules on the whole host page, with fallback fonts. `devStylesheet.test.ts` pins it.
+- **One build per UI change.** `npm run build` and `npm run build:prod` write `resources/views/prepaint.html` before the bundle; `npm run build:prepaint` still works alone. `committed-build.yml` compares the prepaint view as well as `public/assets`.
+- **The Composer archive leaves out `e2e/`, `.dependency-cruiser.cjs` and `tsconfig.domain.json`**, which the previous change had added (484 files, from 529).
- `core/minifiedBundle.ts`, used only by its test, moved to `resources/js/test/minifiedBundle.ts`; dependency-cruiser's `no-unreachable` rule reported it.
### Fixed
diff --git a/README.md b/README.md
index 8633d6f..4762a7c 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ The docs live in this repository, under [`docs/`](docs/).
- [REST mode in a non-Inertia app](docs/recipes/rest-mode.md) — the API without the Inertia pages
- [Grant admin access](docs/recipes/grant-admin-access.md) — one Gate for status and the cache
- [Turn features off](docs/recipes/turn-features-off.md) — hide what the host does not offer
+- [Work on the browser UI](docs/recipes/frontend-development.md) — dev server with hot reload, checks, e2e and the one build
### Ecosystem
diff --git a/docs/architecture.md b/docs/architecture.md
index 3d32488..7cb4762 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -33,11 +33,18 @@ that repository's built library, vendored under `resources/vendor/react-icon-bro
radio buttons, toggle, featured icon and `uui-theme.css`) come from
`@ichava/js-ui-kernel/react`, pinned to `#v0.1.0` (the tree of `bc5120c`). Their old paths are deprecated re-exports.
The tabs and the dropdown stay local.
-- The standalone demo (`main.tsx`, `StandaloneApp.tsx`, `embedded.tsx`, `components/boot/`) came
- with the snapshot and is not built here.
-- `.dev/browser/` holds the browser's build plugins, generators and the fixture catalogue its tests
- read. `.dev/imported/react-icon-browser/` holds the rest of that repository's root files
- (configs, docs, e2e, the standalone pages). Both are left out of the Composer archive.
+- The standalone demo (`main.tsx`, `StandaloneApp.tsx`, `embedded.tsx`, `components/boot/`) is not
+ shipped. It is the fixture react-icon-browser's Playwright suite drives: `e2e/browser/` holds the
+ specs, their baselines and the two harness pages, which `e2e/browser/vite.config.ts` builds from
+ `resources/js` with a static catalog (`npm run e2e:browser`, `npm run visual`).
+- `.dev/browser/` holds the browser's build plugins, generators, guard scripts and the fixture
+ catalogue its tests read. `.dev/imported/react-icon-browser/` holds the rest of that repository's
+ root files (its docs, configs and manifest). `.dev/`, `e2e/` and the tests are left out of the
+ Composer archive.
+- The layering rules came across too: `.dependency-cruiser.cjs` (`npm run boundaries`, proven able
+ to fail by `npm run check:boundary-rules`) and the stricter `tsconfig.domain.json` for
+ `modules/*/domain` and `application` (`npm run typecheck:domain`). `.github/workflows/browser.yml`
+ runs them, the other guards and the e2e suite on every pull request, and the visual suite weekly.
`resources/js` is also the Inertia adapter around the browser:
@@ -87,8 +94,8 @@ baselines of the private Metronic pack) were not imported.
`sync:react-icon-browser` and `check:consumers` scripts that had only printed where the work
moved. The one file still read from it, `prepaint.html`, now lives at
`resources/views/prepaint.html`: the `` component inlines it,
-`npm run build:prepaint` writes it from `resources/js/boot/prepaint.html`, and a Vitest test
-fails while the two differ.
+`npm run build:prod` writes it from `resources/js/boot/prepaint.html` before it builds the bundle
+(`npm run build:prepaint` does it alone), and a Vitest test fails while the two differ.
The look comes with the library: since the 0.1.0 restyle the browser draws Ichava's warm greys,
the teal `#0e7c66` default accent, a 6px radius, Tabler chrome icons, square tags and tabs, flat
diff --git a/docs/getting-started.md b/docs/getting-started.md
index c47f8cc..4e5dd51 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -54,6 +54,7 @@ Browser adapts to the host application rather than imposing a stack, so the prot
## See also
+- [Work on the browser UI](recipes/frontend-development.md), to change the React source in `resources/js` with hot reload
- [Configuration](configuration.md)
- [Environment variables](environment.md)
- [Architecture](architecture.md)
diff --git a/docs/recipes/frontend-development.md b/docs/recipes/frontend-development.md
new file mode 100644
index 0000000..2293218
--- /dev/null
+++ b/docs/recipes/frontend-development.md
@@ -0,0 +1,58 @@
+# Work on the browser UI
+
+Change the browser's React source in `resources/js`, see it live in a host, then check and build it with one command each.
+
+## Run it with hot reload
+
+Start the dev server from this checkout, and let a host that has the package installed load from it:
+
+```bash
+npm install
+npm run dev # Vite on http://localhost:5174, the Inertia entry resources/js/app.tsx
+```
+
+```dotenv
+# in the host's .env (app.debug must be on)
+ICHAVA_VITE_DEV=true
+```
+
+The layout (`resources/views/app.blade.php`) loads `resources/js/app.tsx` from the dev server, with React Fast Refresh, whenever `ICHAVA_VITE_DEV` is on, `APP_DEBUG` is on and the server answers on `ichava.icon-browser.vite.host` and `.port`. Otherwise it loads the committed bundle. The dev server serves the browser's stylesheet scoped to its root and its fonts from `resources/assets/fonts`, as the build ships them; `resources/js/core/devStylesheet.test.ts` pins that.
+
+## Check it
+
+```bash
+npm run typecheck:inertia && npm run lint && npm run test:js
+npm run typecheck:domain && npm run typecheck:e2e
+npm run boundaries && npm run check:boundary-rules
+npm run check:schema-types && npm run check:dead-links
+```
+
+The Playwright suite drives the browser's standalone pages, built from `resources/js` by `e2e/browser/vite.config.ts`, with a static catalog and no Laravel host:
+
+```bash
+npx playwright install chromium
+npm run e2e:browser # 98 behavioural tests
+npm run visual:linux:check # 12 screenshots, inside CI's Playwright image (needs Docker)
+```
+
+Screenshots are compared only in `mcr.microsoft.com/playwright:v1.65.0-noble`; `npm run check:playwright-image` fails when that tag drifts between `browser.yml`, `visual-baselines.yml`, `.dev/browser/linux.sh` and `@playwright/test`. To regenerate baselines for a pull request, dispatch the seeding workflow on its branch; it commits the changed PNGs and comments the drift:
+
+```bash
+gh workflow run visual-baselines.yml --repo ichava/icon-browser --ref -f commit=true
+```
+
+## Build it
+
+```bash
+npm run build:prod # public/assets and resources/views/prepaint.html
+npm run check:dead-classes # reads the stylesheet the build wrote
+```
+
+Commit both outputs with the source change. `committed-build.yml` rebuilds every pull request and fails when either differs. Two traps:
+
+- `resources/assets/styles/ichava.scss` is a second Tailwind root whose utilities are global. It scans the repository except the paths it lists under `@source not`, so a word in a new top-level folder can become a class on every host page. Rebuild and check the diff of `public/assets/css/inertia-app.css`.
+- No lock file is committed, so a rebuild on another day can pick up new dependency releases.
+
+---
+
+[← Docs index](../../README.md#documentation)
diff --git a/docs/release.md b/docs/release.md
index 0782060..9e3d8b7 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -64,11 +64,11 @@ The browser's source lives in `resources/js` since 2026-11-12 (see [Architecture
```bash
npm install
npm run typecheck:inertia && npm run lint && npm run test:js
-npm run build:prod # then commit public/assets
-npm run build:prepaint # only when resources/js/boot/prepaint.html changed
+npm run e2e:browser # the browser's Playwright suite, on its standalone harness
+npm run build:prod # then commit public/assets and resources/views/prepaint.html
```
-`npm run build:prepaint` writes `resources/views/prepaint.html`, which the prepaint component inlines; commit it with the bundle. The vendored `resources/vendor/react-icon-browser` and its `sync:react-icon-browser` and `build:lib` scripts were removed on 2026-11-13.
+`npm run build:prod` is the one build: it writes `resources/views/prepaint.html`, which the prepaint component inlines, and then the bundle. Commit both; `committed-build.yml` fails when either differs from a fresh build. [Work on the browser UI](recipes/frontend-development.md) covers the dev server, every check and the visual baselines. The vendored `resources/vendor/react-icon-browser` and its `sync:react-icon-browser` and `build:lib` scripts were removed on 2026-11-13.
The source and the committed bundle contain Untitled UI PRO components, used under the Untitled UI licence as part of this product ([LICENSE](../LICENSE), [Architecture](architecture.md#untitled-ui-pro-components)). The repository is public (decided 2026-10-30) and the package stays proprietary. A release tag on the public repository publishes normally: `release.yml` does not gate on repository visibility. Its other safeguards stand, namely the tag-only trigger, the refusal of any ref that is not a `vX.Y.Z` tag, and the changelog section it extracts and fails closed without.
diff --git a/package.json b/package.json
index f5a97d6..74e3f6c 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,8 @@
"check:config-drift": "node .dev/tools/check-config-drift.mjs eslint.base.js=config/eslint.base.js tsconfig.base.json=config/tsconfig.base.json .dev/tools/sync-vendored-library.mjs=config/tools/sync-vendored-library.mjs .dev/tools/check-config-drift.mjs=config/check-config-drift.mjs",
"// clean": "Narrow on purpose. inertia-app.js/.css are CHECKED IN and no npm script or CI job rebuilds them -- only build does. rm -rf public/* destroyed them, and a failing vite build destroyed them for nothing. Delete only what the build re-emits. See issue #26.",
"clean": "rm -rf public/assets/js/inertia-app*.js public/assets/css/inertia-app.css public/.vite 2>/dev/null || true",
- "build": "npm run clean && echo '' && echo '🔨 Building Ichava (dev)...' && NODE_ENV=development vite build --config vite.inertia.config.ts",
- "build:prod": "npm run clean && echo '' && echo '🚀 Building Ichava (production)...' && NODE_ENV=production vite build --config vite.inertia.config.ts",
+ "build": "npm run clean && npm run -s build:prepaint && echo '' && echo '🔨 Building Ichava (dev)...' && NODE_ENV=development vite build --config vite.inertia.config.ts",
+ "build:prod": "npm run clean && npm run -s build:prepaint && echo '' && echo '🚀 Building Ichava (production)...' && NODE_ENV=production vite build --config vite.inertia.config.ts",
"watch": "echo '' && echo '👀 Watching Ichava for changes...' && vite build --watch --config vite.inertia.config.ts",
"dev": "vite --config vite.inertia.config.ts",
"test:js": "vitest run",
diff --git a/resources/js/core/devStylesheet.test.ts b/resources/js/core/devStylesheet.test.ts
new file mode 100644
index 0000000..529dcd2
--- /dev/null
+++ b/resources/js/core/devStylesheet.test.ts
@@ -0,0 +1,54 @@
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { createServer, type ViteDevServer } from 'vite';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+/**
+ * `npm run dev` serves the browser's stylesheet as a host receives it (react consolidation 4.1,
+ * 2026-11-13): scoped to the browser's root, with fonts that load.
+ *
+ * vite.inertia.config.ts scoped the stylesheet and rewrote its font URLs in the build only, so
+ * under the dev server the browser's tokens sat on `:root`, its rules reached the whole host page
+ * and its fonts asked for `/fonts/...`, which nothing serves. This asks the dev server for the
+ * stylesheet in middleware mode, which needs no port, and checks what it would send.
+ */
+const root = process.cwd();
+let server: ViteDevServer;
+let css = '';
+
+beforeAll(async () => {
+ server = await createServer({
+ configFile: join(root, 'vite.inertia.config.ts'),
+ server: { middlewareMode: true, hmr: false, ws: false },
+ logLevel: 'error',
+ });
+ css = (await server.transformRequest('/resources/js/styles/theme.css?direct'))?.code ?? '';
+}, 60_000);
+
+afterAll(async () => {
+ await server?.close();
+});
+
+describe('the dev server serves the browser stylesheet as the build ships it', () => {
+ it('serves a stylesheet at all', () => {
+ expect(css.length).toBeGreaterThan(50_000);
+ });
+
+ it('scopes the utilities to the browser root', () => {
+ expect(css).toMatch(/@layer utilities\s*\{\s*@scope \(\[data-ichava-theme\]\)/);
+ });
+
+ it('declares no browser token on the host document', () => {
+ const onRoot = [...css.matchAll(/(?:^|[}\s]):root\b[^{]*\{([^}]*)\}/g)].filter((m) => /--(color|ich|bg|fg|accent)/.test(m[1] ?? ''));
+ expect(onRoot.map((m) => m[0].slice(0, 80))).toEqual([]);
+ });
+
+ it('points every font at a file the dev server can serve', () => {
+ const urls = [...css.matchAll(/url\((['"]?)([^'")]*\.woff2)\1\)/g)].map((m) => m[2] ?? '');
+ expect(urls.length).toBeGreaterThanOrEqual(2);
+ for (const url of urls) {
+ expect(url, url).toMatch(/^\/resources\/assets\/fonts\//);
+ expect(existsSync(join(root, url)), url).toBe(true);
+ }
+ });
+});
diff --git a/vite.inertia.config.ts b/vite.inertia.config.ts
index b34ef89..580630a 100644
--- a/vite.inertia.config.ts
+++ b/vite.inertia.config.ts
@@ -42,13 +42,15 @@ const BROWSER_SCOPE = '[data-ichava-theme]';
* `scopeThemeTokens` and `scopeUtilityLayer`. Those ran over every emitted stylesheet; here the
* browser's CSS shares one bundle with this package's own (ichava.scss, pages.css), whose
* `:root` and `body` rules must stay global. So this runs on the browser's module alone, after
- * Tailwind has compiled and minified it, and before Vite joins the stylesheets. Build only, like
- * the two plugins it replaces.
+ * Tailwind has compiled it, and before Vite joins the stylesheets.
+ *
+ * It runs under `npm run dev` too (react consolidation 4.1, 2026-11-13). It used to be build only,
+ * so the dev server put the browser's tokens on `:root` and its rules on the whole host page, and
+ * a page looked different in dev from what a host receives. `DevStylesheetTest` pins both modes.
*/
function browserStylesheet(): Plugin {
return {
name: 'ichava:browser-stylesheet',
- apply: 'build',
transform(code, id) {
if (id.split('?')[0] !== BROWSER_STYLESHEET) return null;
return { code: scopeSheet(scopeTokens(code, BROWSER_SCOPE), BROWSER_SCOPE), map: null };
@@ -85,13 +87,30 @@ function shipFonts(): Plugin {
};
}
+/**
+ * Serve the fonts under `npm run dev`. theme.css asks for `/fonts/...`, which only the build
+ * rewrites (shipFonts); the dev server serves the repository at its root, so in dev the URLs point
+ * at the files in resources/assets/fonts instead of at a path nothing serves.
+ */
+function devFonts(): Plugin {
+ return {
+ name: 'ichava:browser-dev-fonts',
+ apply: 'serve',
+ enforce: 'post',
+ transform(code, id) {
+ if (id.split('?')[0] !== BROWSER_STYLESHEET) return null;
+ return { code: code.replace(/url\((['"]?)\/fonts\//g, 'url($1/resources/assets/fonts/'), map: null };
+ },
+ };
+}
+
/** package.json's version, for the browser's `config.meta.version`. */
const VERSION = (JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }).version;
export default defineConfig({
base: './',
define: { __ICHAVA_BROWSER_VERSION__: JSON.stringify(VERSION) },
- plugins: [react(), tailwindcss(), browserStylesheet(), shipFonts()],
+ plugins: [react(), tailwindcss(), browserStylesheet(), shipFonts(), devFonts()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),