@webjsdev/cli 0.10.66 → 0.10.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -456,6 +456,12 @@ async function refuseOutsideApp(command) {
456
456
  }
457
457
 
458
458
  async function main() {
459
+ // A `bun --hot` re-run of the dev server child (#1575): the first run's
460
+ // server owns the process, so hand it the reload and stop here.
461
+ if (cmd === 'dev' && process.env.__WEBJS_DEV_CHILD === '1' && process.versions.bun) {
462
+ const { rerunHotDevServer } = await import('../lib/dev-hot-rerun.js');
463
+ if (await rerunHotDevServer()) return;
464
+ }
459
465
  // `--version` / `-v` (top level): print the installed CLI version and exit.
460
466
  if (cmd === '--version' || cmd === '-v') {
461
467
  console.log(readCliVersion());
@@ -526,7 +532,11 @@ async function main() {
526
532
  // parent is gone (killed outright, or crashed), and a child left
527
533
  // running would hold the port against the next `webjs dev`. Unref'd
528
534
  // so the channel itself never keeps this process alive.
529
- if (process.connected) {
535
+ // Once per process: `bun --hot` re-runs this file on every edit
536
+ // (#1575), and each run used to add another listener.
537
+ const g = /** @type {any} */ (globalThis);
538
+ if (process.connected && !g.__webjsDevDisconnectHooked) {
539
+ g.__webjsDevDisconnectHooked = true;
530
540
  process.channel?.unref?.();
531
541
  process.on('disconnect', () => process.exit(0));
532
542
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The app icon a new scaffold starts with, and how `webjs doctor` recognises
3
+ * an icon that was never replaced.
4
+ *
5
+ * A scaffold ships `app/icon.svg` (auto-linked as the favicon, served at
6
+ * /icon.svg and as the /favicon.ico fallback) and `app/manifest.webmanifest`
7
+ * (auto-linked as the web app manifest). The icon is a deliberately NEUTRAL
8
+ * placeholder: a grey tile with a dashed frame, so a tab strip shows at a
9
+ * glance that the app has no icon of its own yet, and no app ever ships
10
+ * looking like a WebJs demo. It carries `data-webjs-placeholder` so a check
11
+ * (webjs doctor, or any agent's own tester) can tell it apart from a real
12
+ * icon without comparing bytes.
13
+ */
14
+
15
+ /** The attribute that marks the scaffold's placeholder icon. */
16
+ export const PLACEHOLDER_MARKER = 'data-webjs-placeholder';
17
+
18
+ /** The placeholder `app/icon.svg`. Replace it with the app's own mark. */
19
+ export const PLACEHOLDER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" ${PLACEHOLDER_MARKER}="icon">
20
+ <!-- PLACEHOLDER app icon from \`webjs create\`. Replace this file with the
21
+ app's own icon: a simple symbol for what the app is, in its colours,
22
+ legible at 16px. Keep the file name (app/icon.svg) and the framework
23
+ links it, serves it and answers /favicon.ico with it. -->
24
+ <rect width="32" height="32" rx="8" fill="#d4d4d8"/>
25
+ <rect x="7" y="7" width="18" height="18" rx="3" fill="none" stroke="#71717a" stroke-width="2" stroke-dasharray="3 2.4"/>
26
+ </svg>
27
+ `;
28
+
29
+ /**
30
+ * The `app/manifest.webmanifest` a scaffold starts with: the app's name, the
31
+ * neutral colours of the scaffold palette, and the icon. Grow it with the app
32
+ * (its real theme colour, a 192 and 512 PNG for installability).
33
+ * @param {string} name the app's display name
34
+ */
35
+ export function appManifest(name) {
36
+ return JSON.stringify({
37
+ name,
38
+ short_name: name,
39
+ start_url: '/',
40
+ display: 'standalone',
41
+ background_color: '#ffffff',
42
+ theme_color: '#ffffff',
43
+ icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }],
44
+ }, null, 2) + '\n';
45
+ }
46
+
47
+ /**
48
+ * Whether an SVG is the WebJs brand mark earlier scaffolds shipped as
49
+ * `public/favicon.svg` (a rounded square with the gallery's grey gradient).
50
+ * Apps made before the placeholder still serve it as their favicon.
51
+ * @param {string} svg
52
+ */
53
+ export function isLegacyBrandFavicon(svg) {
54
+ return /aria-label="WebJs"/.test(svg) && /<linearGradient id="wj"/.test(svg);
55
+ }
package/lib/create.js CHANGED
@@ -21,6 +21,7 @@ import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi, bunifyEnvExampl
21
21
  import { postgresCompose, postgresCi } from './db-rewrite.js';
22
22
  import { assertValidAppName, toDatabaseName } from './app-name.js';
23
23
  import { isGalleryAppShellFile } from './gallery-shell-files.js';
24
+ import { PLACEHOLDER_ICON_SVG, appManifest } from './app-icon.js';
24
25
  import { detectPackageManager } from './package-manager.js';
25
26
 
26
27
  /**
@@ -1246,10 +1247,15 @@ export type ActionResult<T> =
1246
1247
  const swSrc = join(TEMPLATES, 'public', swFile);
1247
1248
  if (existsSync(swSrc)) await cp(swSrc, join(publicDir, swFile));
1248
1249
  }
1249
- // A base SVG favicon (the root layout links it). It ships with the app, not
1250
- // the gallery, so it survives `npm run gallery:clear`.
1251
- const faviconSrc = join(TEMPLATES, 'public', 'favicon.svg');
1252
- if (existsSync(faviconSrc)) await cp(faviconSrc, join(publicDir, 'favicon.svg'));
1250
+ // The app icon and web app manifest: `app/icon.svg` (a neutral PLACEHOLDER
1251
+ // the agent replaces with the app's own icon; `webjs doctor` warns while it
1252
+ // is still there) and `app/manifest.webmanifest` (the app's name). The
1253
+ // framework links both into <head> and answers /favicon.ico with the icon,
1254
+ // so the layout declares nothing. They ship with the app, not the gallery,
1255
+ // so they survive `gallery:clear`.
1256
+ await mkdir(join(appDir, 'app'), { recursive: true });
1257
+ await writeFile(join(appDir, 'app', 'icon.svg'), PLACEHOLDER_ICON_SVG);
1258
+ await writeFile(join(appDir, 'app', 'manifest.webmanifest'), appManifest(displayName));
1253
1259
 
1254
1260
  // The gallery-reset script (wired as `gallery:clear`). Only UI templates have
1255
1261
  // a gallery, so it ships here (NOT in the flat templateFiles list, which would
@@ -1355,11 +1361,10 @@ import '#components/theme-toggle.ts';
1355
1361
  * text-foreground, bg-card, bg-primary, and border-border all work.
1356
1362
  */
1357
1363
 
1358
- // Declare the favicon via metadata.icons (NOT a hand-written <link> in the
1359
- // template): the framework emits metadata links into <head>, whereas a <link>
1360
- // written in the layout body stays in <body>, where browsers ignore it. The SVG
1361
- // lives at public/favicon.svg and serves at /public/favicon.svg.
1362
- export const metadata = { icons: '/public/favicon.svg' };
1364
+ // The favicon and manifest are app/icon.svg and app/manifest.webmanifest: the
1365
+ // framework links them into <head> itself, so nothing is declared here. Replace
1366
+ // the placeholder icon with this app's own (see the skill's routing-and-pages
1367
+ // reference, "App icon").
1363
1368
 
1364
1369
  // LayoutProps types every layout argument (children, params, searchParams,
1365
1370
  // url) from the framework, so children is a TemplateResult rather than an
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The fast path for a `bun --hot` re-run of the `webjs dev` server child
3
+ * (#1575).
4
+ *
5
+ * `bun --hot` re-evaluates this CLI on every reload. The first run's server
6
+ * owns the process (`dev/hot-host.js` in `@webjsdev/server`) and only needs to
7
+ * hear that the module registry was reset, so a re-run calls the host directly
8
+ * instead of importing the whole server again just to reach `startServer`,
9
+ * which re-evaluated every framework module on every edit for nothing.
10
+ *
11
+ * The one thing a re-run must still notice is the framework itself changing
12
+ * under it (an upgrade while `webjs dev` runs): the first run's code cannot
13
+ * load the new copy in place, so the child exits and the supervisor starts a
14
+ * fresh process.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const HOSTS = Symbol.for('webjs.dev.hotHosts');
21
+
22
+ /** The installed `@webjsdev/server` version, or '' when it cannot be read. */
23
+ function installedServerVersion() {
24
+ try {
25
+ const entry = fileURLToPath(import.meta.resolve('@webjsdev/server'));
26
+ return JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8')).version || '';
27
+ } catch {
28
+ return '';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Hand a re-run to the live dev server. Returns false when there is none (the
34
+ * first run), so the caller starts the server as usual.
35
+ *
36
+ * @param {{ exit?: (code: number) => void, log?: (line: string) => void }} [io]
37
+ * @returns {Promise<boolean>}
38
+ */
39
+ export async function rerunHotDevServer(io = {}) {
40
+ const exit = io.exit || ((code) => process.exit(code));
41
+ const log = io.log || ((line) => console.log(line));
42
+ const hosts = /** @type {any} */ (globalThis)[HOSTS];
43
+ if (!(hosts instanceof Map) || hosts.size === 0) return false;
44
+ const version = installedServerVersion();
45
+ for (const host of hosts.values()) {
46
+ if (version && host.version && host.version !== version) {
47
+ log(`[webjs] @webjsdev/server changed (${host.version} -> ${version}), restarting the dev server`);
48
+ exit(0);
49
+ return true;
50
+ }
51
+ }
52
+ for (const host of hosts.values()) await host.rerun();
53
+ return true;
54
+ }
package/lib/dev-reload.js CHANGED
@@ -32,6 +32,13 @@ import { PORT_IN_USE_EXIT_CODE } from './port.js';
32
32
  */
33
33
  export const RESTART_DEBOUNCE_MS = 50;
34
34
 
35
+ /**
36
+ * The IPC message a dev server child sends when it reloads every module in
37
+ * place under `bun --hot`, plugin-served ones included (#1575). Mirrors
38
+ * `HOT_IN_PLACE_MESSAGE` in `@webjsdev/server`'s `dev/hot-host.js`.
39
+ */
40
+ export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place';
41
+
35
42
  /**
36
43
  * Delays before restarting a child that exited on its own (a crash), indexed
37
44
  * by consecutive crash count and capped at the last entry. A file change
@@ -338,12 +345,35 @@ export function createSupervisor({
338
345
  };
339
346
  }
340
347
 
348
+ /**
349
+ * Which changed paths restart the live child. A Bun child that reloads
350
+ * plugin-served modules in place announces it over IPC once it is up (#1575);
351
+ * from then on only `plan.inPlaceRestartFor` (the boot hooks) restarts it.
352
+ * Per child: `reset()` on every spawn, so a child that never announces (an
353
+ * older `@webjsdev/server`) keeps the restarts it needs.
354
+ *
355
+ * @param {{ restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean }} plan
356
+ */
357
+ export function inPlaceRestarts(plan) {
358
+ let inPlace = false;
359
+ const base = plan.restartFor || (() => true);
360
+ return {
361
+ /** @param {string} p */
362
+ restartFor: (p) => (inPlace && plan.inPlaceRestartFor ? plan.inPlaceRestartFor(p) : base(p)),
363
+ /** @param {unknown} msg */
364
+ onMessage: (msg) => {
365
+ if (msg && typeof msg === 'object' && /** @type {any} */ (msg).webjs === HOT_IN_PLACE_MESSAGE) inPlace = true;
366
+ },
367
+ reset: () => { inPlace = false; },
368
+ };
369
+ }
370
+
341
371
  /**
342
372
  * Run the dev server under the supervisor until a signal stops it.
343
373
  *
344
374
  * @param {{
345
375
  * cwd: string,
346
- * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] },
376
+ * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] },
347
377
  * env: NodeJS.ProcessEnv,
348
378
  * onExit: (code: number) => void,
349
379
  * }} opts
@@ -366,14 +396,16 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
366
396
  sup.stop().then(() => onExit(code));
367
397
  };
368
398
 
399
+ const restarts = inPlaceRestarts(plan);
369
400
  const sup = createSupervisor({
370
401
  restartOnChange: plan.restartOnChange,
371
- ...(plan.restartFor ? { restartFor: plan.restartFor } : {}),
402
+ ...(plan.restartFor ? { restartFor: restarts.restartFor } : {}),
372
403
  log,
373
404
  // The child already printed why (the port and its holder); stop with its
374
405
  // code rather than restarting a server that can never bind.
375
406
  onFinal: (code) => shutdown(code),
376
407
  spawnChild: () => {
408
+ restarts.reset();
377
409
  const c = spawn(process.execPath, plan.args, {
378
410
  // The IPC channel lets the child notice this process is gone and exit,
379
411
  // so a killed supervisor never leaves an orphan holding the port.
@@ -381,6 +413,7 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
381
413
  cwd,
382
414
  env,
383
415
  });
416
+ c.on('message', restarts.onMessage);
384
417
  // A failed spawn emits 'error' and may never emit 'exit'; report it as
385
418
  // an exit so the backoff retries it (a repeated exit is ignored).
386
419
  c.on('error', (err) => {
@@ -46,7 +46,29 @@ export const WATCH_DIRS = ['app', 'components', 'modules', 'lib', 'actions'];
46
46
  * never restarts the dev server when edited, which is the quiet half of the
47
47
  * bug where a `middleware.ts` was loaded by neither.
48
48
  */
49
- export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
49
+ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs', ...bootFiles()];
50
+
51
+ /**
52
+ * The app-root boot hooks the server runs ONCE per process (#1575):
53
+ * `instrumentation.*` (its `register()`) and `env.*` (the env validation). An
54
+ * edit to one restarts the dev server on either runtime, because no in-place
55
+ * reload re-runs them.
56
+ * @returns {string[]}
57
+ */
58
+ function bootFiles() {
59
+ return ['instrumentation', 'env'].flatMap((n) => ['ts', 'js', 'mts', 'mjs'].map((x) => `${n}.${x}`));
60
+ }
61
+
62
+ const BOOT_FILE = /^(?:instrumentation|env)\.m?[jt]s$/;
63
+
64
+ /**
65
+ * Whether a changed (app-relative) path is a boot hook (see `bootFiles`).
66
+ * @param {string} path
67
+ * @returns {boolean}
68
+ */
69
+ export function isBootFile(path) {
70
+ return BOOT_FILE.test(path);
71
+ }
50
72
 
51
73
  /**
52
74
  * Plan how `webjs dev` runs its server.
@@ -56,7 +78,7 @@ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts',
56
78
  * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
57
79
  * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
58
80
  * @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin.
59
- * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
81
+ * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
60
82
  * `inline` runs the server in this process (no reload watcher); `supervise`
61
83
  * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
62
84
  * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
@@ -71,7 +93,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
71
93
  if (noHot) return { mode: 'inline' };
72
94
 
73
95
  const watch = { watchDirs: [...WATCH_DIRS], watchFiles: [...WATCH_FILES] };
74
- if (isBun) return { mode: 'supervise', args: ['--hot', ...argv], restartOnChange: true, restartFor: bunPluginServed(sourceLocations), ...watch };
96
+ if (isBun) {
97
+ // `restartFor` covers a server that cannot reload a plugin-served module in
98
+ // place. A server that can says so over IPC once it is up (#1575), and the
99
+ // supervisor narrows to `inPlaceRestartFor`: only the boot hooks restart.
100
+ const plugin = bunPluginServed(sourceLocations);
101
+ return {
102
+ mode: 'supervise',
103
+ args: ['--hot', ...argv],
104
+ restartOnChange: true,
105
+ restartFor: (p) => plugin(p) || isBootFile(p),
106
+ inPlaceRestartFor: isBootFile,
107
+ ...watch,
108
+ };
109
+ }
75
110
  return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
76
111
  }
77
112
 
@@ -50,6 +50,7 @@ export const DOCTOR_CODES = {
50
50
  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
51
51
  'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
52
52
  'workspace-overrides': 'WORKSPACE_OVERRIDES',
53
+ 'app-icon': 'APP_ICON',
53
54
  };
54
55
 
55
56
  /**
@@ -0,0 +1,36 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PLACEHOLDER_MARKER, isLegacyBrandFavicon } from '../../app-icon.js';
4
+
5
+ /**
6
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
7
+ */
8
+
9
+ /** @param {string} file */
10
+ function read(file) {
11
+ try { return readFileSync(file, 'utf8'); } catch { return ''; }
12
+ }
13
+
14
+ /**
15
+ * Warn when the app's favicon is still the one `webjs create` shipped: the
16
+ * neutral `app/icon.svg` placeholder, or the WebJs brand mark earlier
17
+ * scaffolds put at `public/favicon.svg`. Either way every app made from the
18
+ * scaffold shows the same tab icon, which reads as a demo rather than a
19
+ * product. An app with no `app/` directory (a library, the api template with
20
+ * no pages) passes.
21
+ *
22
+ * @param {string} appDir
23
+ * @returns {DoctorResult}
24
+ */
25
+ export function checkAppIcon(appDir) {
26
+ const name = 'app-icon';
27
+ if (!existsSync(join(appDir, 'app'))) return { name, status: 'pass', message: 'no app/ directory to analyse' };
28
+ const fix = 'Replace app/icon.svg with an icon for this app (a simple symbol for what it is, in its own colours, legible at 16px), and delete public/favicon.svg plus any metadata.icons that still points at it.';
29
+ if (read(join(appDir, 'app', 'icon.svg')).includes(PLACEHOLDER_MARKER)) {
30
+ return { name, status: 'warn', message: 'app/icon.svg is still the scaffold placeholder icon', fix };
31
+ }
32
+ if (isLegacyBrandFavicon(read(join(appDir, 'public', 'favicon.svg')))) {
33
+ return { name, status: 'warn', message: 'public/favicon.svg is still the WebJs mark an earlier scaffold shipped', fix };
34
+ }
35
+ return { name, status: 'pass', message: 'the app has its own icon (or declares none)' };
36
+ }
@@ -12,6 +12,7 @@ import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
12
12
  import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
13
13
  import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
14
14
  import { checkWorkspaceOverrides } from './probes/workspace-overrides.js';
15
+ import { checkAppIcon } from './probes/app-icon.js';
15
16
 
16
17
  /**
17
18
  * @typedef {import('./codes.js').DoctorResult} DoctorResult
@@ -66,6 +67,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
66
67
  checkStaticAssetFreshness(appDir),
67
68
  checkUnmarkedAssetLinks(appDir),
68
69
  Promise.resolve(checkWorkspaceOverrides(appDir)),
70
+ Promise.resolve(checkAppIcon(appDir)),
69
71
  ]);
70
72
  // Attach the stable machine code to every result (#975). Centralized here so
71
73
  // each check function stays free of the code-contract concern.
@@ -6,7 +6,7 @@
6
6
  * live app deployed on its own, so it needs a root layout, a home page, a theme
7
7
  * toggle, and the `cn()` helper. The scaffold writes all four itself, with
8
8
  * things the gallery's copies cannot carry: the app's `displayName`, the
9
- * `cspNonce()` wiring, `LayoutProps` typing, the `metadata.icons` favicon, and
9
+ * `cspNonce()` wiring, `LayoutProps` typing, and
10
10
  * a `cn.ts` read verbatim from the `@webjsdev/ui` registry so `webjs ui add`
11
11
  * stays in lockstep with the kit.
12
12
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.66",
3
+ "version": "0.10.68",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.81",
21
+ "@webjsdev/server": "^0.8.86",
22
22
  "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
@@ -44,6 +44,7 @@ Rows point rather than explain. The reference is the authority on the rule, and
44
44
  | add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
45
45
  | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
46
46
  | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
47
+ | give the app its own favicon, home-screen icon and manifest | replace the placeholder `app/icon.svg` with a simple symbol for the app in its colours, add `app/apple-icon.png`, edit `app/manifest.webmanifest` | leaving the scaffold placeholder, or a hand-written `<link rel="icon">` | `references/routing-and-pages.md` (App icon and manifest) | `app/icon.ts` |
47
48
  | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
48
49
  | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
49
50
  | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
@@ -174,7 +175,7 @@ Find the right export fast. Load the linked reference for full examples.
174
175
 
175
176
  ### File conventions
176
177
 
177
- `page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`).
178
+ `page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`), and the static app-root icon files (`icon.svg`, `apple-icon.png`, `manifest.webmanifest`, `favicon.ico`), auto-linked into `<head>`. The scaffold's `app/icon.svg` is a placeholder: replace it with the app's own icon.
178
179
 
179
180
  ## Canonical Patterns
180
181
 
@@ -296,6 +296,8 @@ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports
296
296
 
297
297
  Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
298
298
 
299
+ `APP_ICON` warns while the favicon is still the scaffold's: the placeholder `app/icon.svg` (marked `data-webjs-placeholder`) or the WebJs mark older scaffolds shipped at `public/favicon.svg`. Replace it with the app's own icon (`references/routing-and-pages.md`, "App icon and manifest").
300
+
299
301
  ### Dependency audit allowlist
300
302
 
301
303
  `webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it.
@@ -30,7 +30,7 @@ Most of what follows restates widely held component-model advice, ported. Lit's
30
30
 
31
31
  **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
32
 
33
- - A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false.
33
+ - A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`, and `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`, ...) writes the empty value like `?attr` (#1579). So `checked=${isDefault}` and `selected=${i === 0}` behave exactly like `?checked` / `?selected`, though `?attr` stays the clearer spelling for a boolean attribute. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false.
34
34
  - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
35
 
36
36
  **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
@@ -139,6 +139,8 @@ The renderer omits the `action` attribute so the form posts to the page's own ur
139
139
 
140
140
  **A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
141
141
 
142
+ A failing form action does not need to echo the submission back. On the 422 re-render `actionData.values` already carries every submitted text field (any `values` the action returns are layered on top), and with JavaScript on the client router restores what was typed into every control the re-render did not explicitly set (#1581). Return `values` only to normalize or blank a field; see `routing-and-pages.md` for the page side.
143
+
142
144
  Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
143
145
 
144
146
  - `validate` runs on the submitted `FormData`.
@@ -176,6 +176,8 @@ html`<form method="post"><input name="email"></form>`;
176
176
 
177
177
  A plain attribute hole that resolves to `null` or `undefined` omits the attribute on both renderers (#1573), so `method=${null}` and `?method=${false}` both emit nothing and WebJs supplies `method="post"`. An EMPTY string is different: `method=${''}` renders `method=""`, which cannot submit and is refused.
178
178
 
179
+ A boolean in a plain hole on an HTML boolean attribute renders like `?attr` (core 0.7.64+, #1579): `<option selected=${i === 0}>` and `<input type="radio" checked=${v.attending !== 'no'}>` mark only the true one. On an older core the server served `selected="false"` / `checked="false"`, which HTML reads as PRESENT, so the LAST option or radio won on first paint. `?selected=${...}` / `?checked=${...}` is correct on every version.
180
+
179
181
  A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
180
182
 
181
183
  ### `params` and `searchParams` are awaitable AND synchronously readable
@@ -220,6 +220,8 @@ export default function Contact({ actionData }: {
220
220
 
221
221
  How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
222
222
 
223
+ **Typed values survive a failed submission without extra code (#1581).** On a failure re-render `actionData.values` carries EVERY submitted text field, with any `values` the action returned layered on top (the action's own win, so it can normalize or blank a field). So `value=${values.x}` refills a field the action never echoed, with JavaScript off. With JavaScript on, the client router goes further: it snapshots the submitted form and, when the non-2xx re-render is applied in place, puts back what was typed in every text input, textarea, select, radio and checkbox whose server-rendered default did not change. A control the server rendered differently on purpose (refilled, normalized, reset) keeps the server's value. Passwords, files and hidden inputs are never restored. Opt a form out with `data-preserve-values="false"`. Refilling from `actionData.values` is still the right habit, since it is what the no-JS path shows; a checkbox group or multi-select repeats its name, and `values` keeps only the last one, so read those from the submitted `FormData` in the action.
224
+
223
225
  Three responses that are not the happy path:
224
226
 
225
227
  - **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
@@ -276,12 +278,26 @@ export default function robots({ siteUrl }: MetadataRouteContext) {
276
278
 
277
279
  The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless).
278
280
 
279
- **`icon` and `apple-icon` are LINKED for you.** An app that declares no `metadata.icons` gets `<link rel="icon" href="/icon">` and `<link rel="apple-touch-icon" href="/apple-icon">` in the head automatically, for whichever of the two routes it defines (base-path prefixed, since that is where the route answers). No `type` or `sizes` is asserted, because the route picks its content type at request time and the browser sniffs the served one.
281
+ ### App icon and manifest (replace the placeholder)
282
+
283
+ **Every app needs its OWN icon.** `webjs create` ships `app/icon.svg` as a neutral PLACEHOLDER (a grey tile with a dashed frame, marked `data-webjs-placeholder="icon"`) and `app/manifest.webmanifest` with the app's name. Replacing the placeholder is part of building the app, not polish: until you do, the tab, the bookmark and the home-screen icon look like every other unfinished app, and `webjs doctor` warns (`APP_ICON`). Draw a simple symbol for what the app IS (a grid for a tic-tac-toe game, a cup for a cafe, a check for a task list), in the app's own colours, on a 32x32 or 24x24 `viewBox`: a filled rounded tile in the primary colour with one bold shape in its foreground colour reads at 16px. Avoid thin strokes (under 2px at 32px), text longer than one letter, and detail that blurs at tab size. Then set `name`, `short_name`, `theme_color` and `background_color` in `app/manifest.webmanifest` to match.
284
+
285
+ The icon conventions, all at the app ROOT and all auto-linked into `<head>` when the app declares no `metadata.icons`:
286
+
287
+ | File | Served at | Linked as |
288
+ |---|---|---|
289
+ | `app/icon.svg` / `icon.png` / `icon.ico` | `/icon.svg` ... | `<link rel="icon">` with `type`, `sizes="any"` (SVG) or the PNG's real pixel size |
290
+ | `app/apple-icon.png` (180x180) | `/apple-icon.png` | `<link rel="apple-touch-icon" sizes="180x180">` (iOS needs PNG, not SVG) |
291
+ | `app/icon.ts` / `apple-icon.ts` | `/icon`, `/apple-icon` | bare link (the route picks its content type per request) |
292
+ | `app/manifest.webmanifest` / `manifest.json` / `manifest.ts` | `/manifest.webmanifest`, `/manifest.json` | `<link rel="manifest">` (`metadata.manifest` wins; `manifest: null` opts out) |
293
+ | `app/favicon.ico` | `/favicon.ico` | not linked; browsers request it themselves |
294
+
295
+ `/favicon.ico` always answers: `public/favicon.ico`, else `app/favicon.ico`, else the app's icon (raster preferred over SVG, then the `icon.ts` route), so a crawler or feed reader that reads no markup gets the same icon. A static icon file wins the link over an icon route when both exist (the route still serves at its URL). Raster icons are linked before SVG, because Google's favicon crawler takes the first usable icon and wants a square raster. Use `icon.ts` only when the mark must be computed per request (per theme, per tenant); a route can render a PNG for `apple-icon.ts` the same way.
280
296
 
281
- Declaring `metadata.icons` **suppresses** the routes rather than merging with them, which is what Next does with its static icon files. So an app that outgrows a placeholder `app/icon.ts` names its real icons and the route stops being linked without having to be deleted:
297
+ Declaring `metadata.icons` **suppresses** all of the above rather than merging with them, which is what Next does with its static icon files. So name icons explicitly only when they live elsewhere (a CDN, `public/`):
282
298
 
283
299
  ```ts
284
- // app/layout.ts -> these win; /icon and /apple-icon are no longer linked
300
+ // app/layout.ts -> these win; app/icon.* and app/apple-icon.* are no longer linked
285
301
  export const metadata = {
286
302
  icons: {
287
303
  icon: [
@@ -293,7 +309,7 @@ export const metadata = {
293
309
  };
294
310
  ```
295
311
 
296
- Declare a favicon through `metadata.icons` (or a metadata route), never as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. A `public/favicon.ico` needs no declaration either way, since the framework serves it at the origin root for crawlers that read no markup.
312
+ Never write a favicon as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout.
297
313
 
298
314
  `opengraph-image` and `twitter-image` are LINKED too, Next's behaviour: a page that declares no `openGraph.images` gets `og:image` pointing at the nearest `opengraph-image` route above it (absolute against the site URL), and likewise `twitter:image`. A page that declares its own image keeps it.
299
315
 
@@ -32,21 +32,21 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
32
32
  | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
33
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
34
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
- | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | `bun --hot`, plus a restart for a `*.server.*` edit and, with source locations on, any app module edit (#1550) |
35
+ | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
- | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398), unless source locations are on |
38
+ | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
39
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
40
40
 
41
41
  **`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page.
42
42
 
43
43
  **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
44
44
 
45
- Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. It cannot reload a module a `Bun.plugin` serves, though (#1550): every `'use server'` `*.server.*` module (the action-seeding plugin) and, when dev source locations are on (`WEBJS_SOURCE_LOCATIONS=1`), every app module (the source-locations plugin). The supervisor restarts the server for an edit to one of those, so on Bun with source locations on every app edit is a full reload, as on Node. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
45
+ Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
46
 
47
47
  **`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
48
48
 
49
- **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an edit `bun --hot` cannot reload in place (a `*.server.*` module, or with source locations on any app module, #1550), and otherwise does only the crash recovery. Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
49
+ **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
50
50
 
51
51
  The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
52
52
 
@@ -3,12 +3,14 @@
3
3
  // content type, so an inline SVG needs no asset file. Generate it dynamically
4
4
  // (per-theme, per-tenant) when the mark must be computed at request time.
5
5
  //
6
- // This is the DEMO of that surface, not the gallery's own favicon. A metadata
7
- // route is not auto-linked: the framework emits `<link rel="icon">` only from
8
- // metadata.icons, so the gallery declares the static WebJs brand mark from
9
- // public/ there (see app/layout.ts) and this route stays browsable at /icon.
10
- // For a favicon that never changes, that static path is the one to copy; drop
11
- // this route when your app has no request-time mark to compute.
6
+ // This is the DEMO of that surface, not the gallery's own favicon. With no
7
+ // metadata.icons declared, the framework auto-links an app-root icon: a STATIC
8
+ // file (app/icon.svg, app/icon.png) wins that link over this route, and a
9
+ // declared metadata.icons wins over both. The gallery declares its WebJs brand
10
+ // mark in app/layout.ts, so this route stays browsable at /icon without being
11
+ // the tab icon. For an icon that never changes, write app/icon.svg instead
12
+ // (what `webjs create` ships); keep a route like this only when the mark must
13
+ // be computed at request time (per theme, per tenant).
12
14
  export default function Icon() {
13
15
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
14
16
  <rect width="32" height="32" rx="7" fill="#1e2226"/>
@@ -3,6 +3,9 @@
3
3
  // icons to your app; pair it with the opt-in service worker for an installable
4
4
  // PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so
5
5
  // set the real app name here by hand rather than expecting substitution.)
6
+ // The framework links an app-root manifest into <head> by itself. A static
7
+ // app/manifest.webmanifest (what `webjs create` ships) wins that link over this
8
+ // route; write a route like this only when a value must be computed.
6
9
  export default function Manifest() {
7
10
  return {
8
11
  name: 'webjs app',
@@ -12,7 +15,7 @@ export default function Manifest() {
12
15
  background_color: '#ffffff',
13
16
  theme_color: '#1e2226',
14
17
  icons: [
15
- { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
18
+ { src: '/icon', sizes: 'any', type: 'image/svg+xml' },
16
19
  ],
17
20
  };
18
21
  }
@@ -191,9 +191,9 @@ import type { LayoutProps } from '@webjsdev/core';
191
191
  * to pull primitives, then theme them here.
192
192
  */
193
193
 
194
- // Favicon via metadata.icons so the framework emits the <link> into <head> (a
195
- // hand-written <link> in the template body is ignored by browsers).
196
- export const metadata = { icons: '/public/favicon.svg' };
194
+ // The favicon is app/icon.svg (and the manifest app/manifest.webmanifest): the
195
+ // framework links both into <head>, so nothing is declared here. Replace the
196
+ // placeholder icon with this app's own.
197
197
 
198
198
  export default function RootLayout({ children }: LayoutProps) {
199
199
  return html\`
@@ -1,12 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="WebJs">
2
- <!-- Matches the gallery navbar brand mark (a rounded square with a
3
- foreground -> muted-foreground gradient), using the DARK-theme token
4
- values (#dee2e6 -> #94989c) so it reads on a dark browser tab bar. -->
5
- <defs>
6
- <linearGradient id="wj" x1="0" y1="0" x2="1" y2="1">
7
- <stop offset="0" stop-color="#dee2e6"/>
8
- <stop offset="1" stop-color="#94989c"/>
9
- </linearGradient>
10
- </defs>
11
- <rect width="32" height="32" rx="10" fill="url(#wj)"/>
12
- </svg>