@iyulab/modern-app 0.34.1 → 0.35.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.35.0] - 2026-10-06
4
+
5
+ ### Changed
6
+
7
+ - **Breaking — the JSX types for the plain content-primitive tags moved to `@iyulab/modern-app/react`.**
8
+ The main entry's type declarations imported `react`, so a TypeScript app without React (`react` is
9
+ an optional peer) failed to type-check with `skipLibCheck: false` — seven `TS2307: Cannot find
10
+ module 'react'` errors from `ActionBar`, `EmptyState`, `GroupBox`, `InfoField`, `InfoSection`,
11
+ `MasterDetailLayout` and `PageHeader`. The main entry no longer references React. A React app
12
+ that imports anything from `@iyulab/modern-app/react` keeps the tag types as before; one that
13
+ only writes the plain tags in JSX adds `import type {} from '@iyulab/modern-app/react'` once.
14
+
15
+ ### Documentation
16
+
17
+ - **The Quick Start's menu icons render.** Its `icon: 'home'` / `'users'` had no `lib`, so they were
18
+ looked up in the app's own `/assets/icons/` and, in a fresh app, drawn as the placeholder. The
19
+ example now names `lib: 'tabler'`, and a new «Icons» section explains where names resolve from.
20
+ - **`routerMode: 'hash'` is described** under Navigation, for static hosts that serve one document.
21
+ - Requires `@iyulab/router` 0.19.1 for shell links to carry the `#` address on a first visit in hash mode.
22
+
23
+ ## [0.34.2] - 2026-10-06
24
+
25
+ ### Fixed
26
+
27
+ - **The content area's scroll keys yield to the control that owns them** (`SidebarLayout`). With focus
28
+ on a button in the content, Space scrolled the page by a screen and the button never fired; with
29
+ focus in a widget that moves with the arrows itself (a tree, a menu, a radio group, a grid), each
30
+ arrow moved inside the widget and also scrolled the content. A key a widget already handled is left
31
+ alone, and Space on a button or link presses it.
32
+
3
33
  ## [0.34.1] - 2026-10-05
4
34
 
5
35
  ### Fixed
package/README.md CHANGED
@@ -29,8 +29,8 @@ await app.load({
29
29
  logo: { src: '/assets/logo.svg', alt: 'My App' },
30
30
  title: 'My App',
31
31
  main: [
32
- { type: 'link', icon: 'home', label: 'Home', href: '/' },
33
- { type: 'link', icon: 'users', label: 'Users', href: '/users' },
32
+ { type: 'link', icon: 'home', lib: 'tabler', label: 'Home', href: '/' },
33
+ { type: 'link', icon: 'users', lib: 'tabler', label: 'Users', href: '/users' },
34
34
  ],
35
35
  },
36
36
  routes: [
@@ -45,6 +45,21 @@ await app.load({
45
45
  });
46
46
  ```
47
47
 
48
+ ### Icons
49
+
50
+ A menu item's `icon` is a name resolved by `<u-icon>` from `@iyulab/components`:
51
+
52
+ - **with `lib`** — from that library. `tabler`, `heroicons`, `lucide` and `bootstrap` are pre-registered
53
+ and fetched from a CDN on first use (the example above uses `tabler`); register your own with
54
+ `IconRegistry.register()`.
55
+ - **without `lib`** — from your own files at `/assets/icons/<name>.svg` (change the folder with
56
+ `setDefaultBaseUrl()`).
57
+
58
+ A name that resolves to nothing is drawn as a neutral placeholder instead of an empty gap, so a
59
+ missing icon set shows up as every item having the same icon. The shell's own controls (menu and
60
+ sidebar toggles, close) use a bundled set and need no network. See the components
61
+ [icon guide](https://github.com/iyulab/node-components/blob/main/docs/icons.md).
62
+
48
63
  ## Skills Usage
49
64
 
50
65
  AI agent skills for this package are located in `skills/modern-app/`. Install them with `npx skills`:
@@ -69,6 +84,16 @@ app.router?.go('/users/42'); // via router instance
69
84
  app.router?.context; // current RouteContext
70
85
  ```
71
86
 
87
+ Routes live in the path (`/users/42`) by default, which needs the server to answer every path with
88
+ the app. On a static host that serves one document (GitHub Pages, object storage), put the route
89
+ after `#` instead:
90
+
91
+ ```typescript
92
+ await app.load({ routerMode: 'hash', /* … */ }); // https://example.com/#/users/42
93
+ ```
94
+
95
+ Links and `app.navigate()` follow the mode, so the rest of the app is written the same way.
96
+
72
97
  ### Theme
73
98
 
74
99
  ```typescript
@@ -214,6 +239,14 @@ properties that a raw element only handles correctly under React 19.
214
239
  npm install @iyulab/modern-app @lit/react react
215
240
  ```
216
241
 
242
+ The JSX types for using the content primitives as plain tags (`<u-info-field>`, `<u-page-header>`, …)
243
+ live in this subpath too, so a non-React app never needs React's types. Importing anything from
244
+ `@iyulab/modern-app/react` brings them in; a React app that only uses the plain tags adds one line:
245
+
246
+ ```ts
247
+ import type {} from '@iyulab/modern-app/react';
248
+ ```
249
+
217
250
  ```tsx
218
251
  import { SidebarLayout, Wizard, type SidebarItem, type WizardStep } from '@iyulab/modern-app/react';
219
252
 
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'danger' | 'main';
4
3
  /**
5
4
  * 푸터 액션 바 — 상세·편집 화면 맨 아래의 같은 골격.
@@ -43,13 +42,4 @@ declare global {
43
42
  'u-action-bar': ActionBar;
44
43
  }
45
44
  }
46
- declare module 'react' {
47
- namespace JSX {
48
- interface IntrinsicElements {
49
- 'u-action-bar': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
50
- sticky?: boolean;
51
- };
52
- }
53
- }
54
- }
55
45
  export {};
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'icon' | 'title' | 'description' | 'actions';
4
3
  /**
5
4
  * 빈 상태 — 목록·검색 결과가 비었을 때.
@@ -50,16 +49,4 @@ declare global {
50
49
  'u-empty-state': EmptyState;
51
50
  }
52
51
  }
53
- declare module 'react' {
54
- namespace JSX {
55
- interface IntrinsicElements {
56
- 'u-empty-state': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
57
- variant?: 'no-data' | 'no-results';
58
- title?: string;
59
- description?: string;
60
- locale?: string;
61
- };
62
- }
63
- }
64
- }
65
52
  export {};
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'header' | 'title' | 'meta' | 'description' | 'actions' | 'body';
4
3
  /** 제목 단계 — 페이지 제목(h1)은 `u-page-header` 몫이라 2 부터다. */
5
4
  export type GroupBoxLevel = 2 | 3 | 4 | 5 | 6;
@@ -66,18 +65,4 @@ declare global {
66
65
  'u-group-box': GroupBox;
67
66
  }
68
67
  }
69
- declare module 'react' {
70
- namespace JSX {
71
- interface IntrinsicElements {
72
- 'u-group-box': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
73
- title?: string;
74
- divider?: boolean;
75
- flush?: boolean;
76
- level?: GroupBoxLevel;
77
- meta?: string;
78
- description?: string;
79
- };
80
- }
81
- }
82
- }
83
68
  export {};
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'label' | 'value' | 'unit' | 'trend';
4
3
  export type InfoFieldFormat = 'number' | 'currency' | 'date';
5
4
  export type InfoFieldSize = 'default' | 'lg';
@@ -103,27 +102,4 @@ declare global {
103
102
  'u-info-field': InfoField;
104
103
  }
105
104
  }
106
- declare module 'react' {
107
- namespace JSX {
108
- interface IntrinsicElements {
109
- 'u-info-field': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
110
- label?: string;
111
- /** 클래스 필드와 동일하게 `unknown` — 렌더 로직이 `String(value)`/포맷터로 무엇이
112
- * 오든 처리하므로 `null`·숫자를 그대로 넘길 수 있다(위 클래스 필드 JSDoc 참조). */
113
- value?: unknown;
114
- blank?: string;
115
- numeric?: boolean;
116
- format?: InfoFieldFormat;
117
- currency?: string;
118
- unit?: string;
119
- size?: InfoFieldSize;
120
- trend?: InfoFieldTrend;
121
- /** 프로퍼티는 `trendLabel`이지만 Lit 기본 속성명 규칙(소문자화, kebab
122
- * 아님)상 실제 HTML 속성명은 `trendlabel`이다. */
123
- trendlabel?: string;
124
- tone?: InfoFieldTone;
125
- };
126
- }
127
- }
128
- }
129
105
  export {};
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'grid';
4
3
  /**
5
4
  * 정보 섹션 — `u-info-field` 들의 반응형 그리드.
@@ -29,13 +28,4 @@ declare global {
29
28
  'u-info-section': InfoSection;
30
29
  }
31
30
  }
32
- declare module 'react' {
33
- namespace JSX {
34
- interface IntrinsicElements {
35
- 'u-info-section': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
36
- min?: number | string;
37
- };
38
- }
39
- }
40
- }
41
31
  export {};
@@ -1,6 +1,5 @@
1
1
  import { PropertyValues } from 'lit';
2
2
  import { StyledElement } from '../internals/StyledElement.js';
3
- import { default as React } from 'react';
4
3
  type ElementParts = 'host' | 'master' | 'divider' | 'detail' | 'detail-close';
5
4
  /**
6
5
  * master›detail 반응형 split-pane 셸.
@@ -92,15 +91,4 @@ declare global {
92
91
  'u-master-detail-layout': MasterDetailLayout;
93
92
  }
94
93
  }
95
- declare module 'react' {
96
- namespace JSX {
97
- interface IntrinsicElements {
98
- 'u-master-detail-layout': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
99
- 'master-size'?: string;
100
- 'overlay-breakpoint'?: number | string;
101
- locale?: string;
102
- };
103
- }
104
- }
105
- }
106
94
  export {};
@@ -1,5 +1,4 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
- import { default as React } from 'react';
3
2
  type ElementParts = 'host' | 'back' | 'heading' | 'eyebrow' | 'title' | 'subtitle' | 'status' | 'actions';
4
3
  /**
5
4
  * 페이지 헤더 — 모든 LOB 화면 최상단의 같은 골격.
@@ -60,18 +59,4 @@ declare global {
60
59
  'u-page-header': PageHeader;
61
60
  }
62
61
  }
63
- declare module 'react' {
64
- namespace JSX {
65
- interface IntrinsicElements {
66
- 'u-page-header': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
67
- title?: string;
68
- subtitle?: string;
69
- eyebrow?: string;
70
- back?: string;
71
- 'back-label'?: string;
72
- locale?: string;
73
- };
74
- }
75
- }
76
- }
77
62
  export {};
@@ -30,23 +30,40 @@ function E(e) {
30
30
  let n = e.getAttribute("role");
31
31
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
32
32
  }
33
- function D(e) {
33
+ var D = /* @__PURE__ */ new Set([
34
+ "button",
35
+ "link",
36
+ "checkbox",
37
+ "switch",
38
+ "radio",
39
+ "menuitem",
40
+ "menuitemcheckbox",
41
+ "menuitemradio",
42
+ "option",
43
+ "tab",
44
+ "treeitem"
45
+ ]);
46
+ function O(e) {
47
+ let t = e.tagName;
48
+ return t === "BUTTON" || t === "SUMMARY" || t === "A" && e.hasAttribute("href") ? !0 : D.has(e.getAttribute("role") ?? "");
49
+ }
50
+ function k(e) {
34
51
  let t = getComputedStyle(e);
35
52
  return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
36
53
  }
37
- function O(e, t) {
54
+ function A(e, t) {
38
55
  for (let n of e) if (n instanceof HTMLElement) {
39
- if (D(n)) return n === t;
56
+ if (k(n)) return n === t;
40
57
  if (n === t) break;
41
58
  }
42
59
  return !0;
43
60
  }
44
- function k() {
61
+ function j() {
45
62
  let e = document.activeElement;
46
63
  for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
47
64
  return e;
48
65
  }
49
- function A(e, t) {
66
+ function M(e, t) {
50
67
  let n = t;
51
68
  for (; n;) {
52
69
  if (n === e) return !0;
@@ -54,7 +71,7 @@ function A(e, t) {
54
71
  }
55
72
  return !1;
56
73
  }
57
- var j = class extends i {
74
+ var N = class extends i {
58
75
  static {
59
76
  C = this;
60
77
  }
@@ -85,7 +102,7 @@ var j = class extends i {
85
102
  }, this.handleOverlaySlotChange = (e) => {
86
103
  let t = o(e.target);
87
104
  if (t && !this.hasOverlay) {
88
- let e = k();
105
+ let e = j();
89
106
  this.overlayTrigger = e && e !== document.body ? e : null;
90
107
  }
91
108
  this.hasOverlay = t;
@@ -97,10 +114,11 @@ var j = class extends i {
97
114
  }, this.handleOverlayClose = () => {
98
115
  this.fire("overlay-close", { cancelable: !1 });
99
116
  }, this._handleMainKeydown = (e) => {
117
+ if (e.defaultPrevented) return;
100
118
  let t = e.composedPath()[0];
101
- if (t instanceof HTMLElement && E(t)) return;
119
+ if (t instanceof HTMLElement && E(t) || e.key === " " && t instanceof HTMLElement && O(t)) return;
102
120
  let n = this.shadowRoot?.querySelector(".main");
103
- if (!n || !O(e.composedPath(), n)) return;
121
+ if (!n || !A(e.composedPath(), n)) return;
104
122
  let r = n.clientHeight;
105
123
  switch (e.key) {
106
124
  case " ":
@@ -350,14 +368,14 @@ var j = class extends i {
350
368
  async focusRoute(e) {
351
369
  let t = this.routeContent();
352
370
  if (await Promise.all(t.map((e) => e.updateComplete)), this.hasOverlay || !e.isConnected) return;
353
- let n = k();
354
- n && t.some((e) => A(e, n)) || this.focusContentStart(t, e);
371
+ let n = j();
372
+ n && t.some((e) => M(e, n)) || this.focusContentStart(t, e);
355
373
  }
356
374
  focusContentStart(e, t) {
357
375
  let n = b(e, "[autofocus]", y);
358
376
  n?.focus();
359
- let r = k();
360
- n && r && A(n, r) || t.focus({ preventScroll: !0 });
377
+ let r = j();
378
+ n && r && M(n, r) || t.focus({ preventScroll: !0 });
361
379
  }
362
380
  overlayPanels() {
363
381
  return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
@@ -365,19 +383,19 @@ var j = class extends i {
365
383
  async focusOverlay() {
366
384
  let e = this.overlayPanels();
367
385
  if (await Promise.all(e.map((e) => e.updateComplete)), !this.hasOverlay) return;
368
- let t = k();
369
- t && e.some((e) => A(e, t)) || (b(e, "[autofocus]", y) ?? b(e, "input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider", y) ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
386
+ let t = j();
387
+ t && e.some((e) => M(e, t)) || (b(e, "[autofocus]", y) ?? b(e, "input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider", y) ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
370
388
  }
371
389
  restoreOverlayFocus() {
372
390
  let e = this.overlayTrigger;
373
391
  this.overlayTrigger = null;
374
- let t = k();
392
+ let t = j();
375
393
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
376
394
  }
377
395
  };
378
396
  r([f({
379
397
  type: String,
380
398
  reflect: !0
381
- }), n("design:type", Object)], j.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], j.prototype, "config", void 0), r([f({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], j.prototype, "counts", void 0), r([f({ type: String }), n("design:type", Object)], j.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", S === void 0 ? Object : S)], j.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], j.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], j.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], j.prototype, "context", void 0), j = C = r([d("u-sidebar-layout")], j);
399
+ }), n("design:type", Object)], N.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], N.prototype, "config", void 0), r([f({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], N.prototype, "counts", void 0), r([f({ type: String }), n("design:type", Object)], N.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", S === void 0 ? Object : S)], N.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], N.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], N.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], N.prototype, "context", void 0), N = C = r([d("u-sidebar-layout")], N);
382
400
  //#endregion
383
- export { j as SidebarLayout };
401
+ export { N as SidebarLayout };
package/dist/react.d.ts CHANGED
@@ -2,6 +2,8 @@ import { default as React } from 'react';
2
2
  import { EventName } from '@lit/react';
3
3
  import { SidebarLayout as SidebarLayoutElement } from './layouts/SidebarLayout.js';
4
4
  import { Wizard as WizardElement, WizardStepChangeDetail } from './components/Wizard.js';
5
+ import { GroupBoxLevel } from './components/GroupBox.js';
6
+ import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
5
7
  export { ScreenObserver } from './internals/ScreenObserver.js';
6
8
  export type { ScreenSize, ScreenObserverConfig, ScreenResizeEvent } from './internals/ScreenObserver.js';
7
9
  /**
@@ -27,3 +29,59 @@ export type WizardProps = React.ComponentProps<typeof Wizard>;
27
29
  export type { SidebarLayoutElement, WizardElement };
28
30
  export type { SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarHtmlConfig, } from './layouts/SidebarLayout.types.js';
29
31
  export type { WizardStep, WizardStepState, WizardStepChangeDetail } from './components/Wizard.js';
32
+ declare module 'react' {
33
+ namespace JSX {
34
+ interface IntrinsicElements {
35
+ 'u-action-bar': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
36
+ sticky?: boolean;
37
+ };
38
+ 'u-empty-state': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
39
+ variant?: 'no-data' | 'no-results';
40
+ title?: string;
41
+ description?: string;
42
+ locale?: string;
43
+ };
44
+ 'u-group-box': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
45
+ title?: string;
46
+ divider?: boolean;
47
+ flush?: boolean;
48
+ level?: GroupBoxLevel;
49
+ meta?: string;
50
+ description?: string;
51
+ };
52
+ 'u-info-field': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
53
+ label?: string;
54
+ /** 클래스 필드와 동일하게 `unknown` — 렌더 로직이 `String(value)`/포맷터로 무엇이
55
+ * 오든 처리하므로 `null`·숫자를 그대로 넘길 수 있다(`InfoField.value` 의 JSDoc 참조). */
56
+ value?: unknown;
57
+ blank?: string;
58
+ numeric?: boolean;
59
+ format?: InfoFieldFormat;
60
+ currency?: string;
61
+ unit?: string;
62
+ size?: InfoFieldSize;
63
+ trend?: InfoFieldTrend;
64
+ /** 프로퍼티는 `trendLabel`이지만 Lit 기본 속성명 규칙(소문자화, kebab
65
+ * 아님)상 실제 HTML 속성명은 `trendlabel`이다. */
66
+ trendlabel?: string;
67
+ tone?: InfoFieldTone;
68
+ };
69
+ 'u-info-section': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
70
+ min?: number | string;
71
+ };
72
+ 'u-master-detail-layout': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
73
+ 'master-size'?: string;
74
+ 'overlay-breakpoint'?: number | string;
75
+ locale?: string;
76
+ };
77
+ 'u-page-header': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
78
+ title?: string;
79
+ subtitle?: string;
80
+ eyebrow?: string;
81
+ back?: string;
82
+ 'back-label'?: string;
83
+ locale?: string;
84
+ };
85
+ }
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/modern-app",
3
3
  "description": "web-framework by iyulab based on lit-element",
4
- "version": "0.34.1",
4
+ "version": "0.35.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -60,7 +60,7 @@
60
60
  "test:react-types": "tsc -p tsconfig.react-smoke.json"
61
61
  },
62
62
  "dependencies": {
63
- "@iyulab/router": "^0.19.0",
63
+ "@iyulab/router": "^0.19.1",
64
64
  "i18next": "^26.3.6",
65
65
  "lit": "^3.3.3"
66
66
  },
@@ -37,8 +37,8 @@ await app.load({
37
37
  logo: { src: '/assets/logo.svg', alt: 'My App' },
38
38
  title: 'My App',
39
39
  main: [
40
- { type: 'link', icon: 'home', label: 'Home', href: '/' },
41
- { type: 'link', icon: 'users', label: 'Users', href: '/users' },
40
+ { type: 'link', icon: 'home', lib: 'tabler', label: 'Home', href: '/' },
41
+ { type: 'link', icon: 'users', lib: 'tabler', label: 'Users', href: '/users' },
42
42
  ],
43
43
  },
44
44
  routes: [
@@ -52,6 +52,10 @@ await app.load({
52
52
  });
53
53
  ```
54
54
 
55
+ Menu `icon` names resolve through `<u-icon>`: with `lib` from that library (`tabler`, `heroicons`, `lucide`,
56
+ `bootstrap` are pre-registered, CDN-fetched), without `lib` from the app's own `/assets/icons/<name>.svg`.
57
+ An unresolved name draws a neutral placeholder — every item showing the same icon means the set is missing.
58
+
55
59
  To tear down the app:
56
60
 
57
61
  ```typescript
@@ -70,6 +74,9 @@ app.router?.context; // current RouteContext
70
74
  app.router?.routes; // registered routes
71
75
  ```
72
76
 
77
+ On a static host that serves one document, `app.load({ routerMode: 'hash' })` keeps the route after `#`
78
+ (`/#/users/42`); links and `app.navigate()` follow the mode.
79
+
73
80
  ---
74
81
 
75
82
  ## Theme