@iyulab/modern-app 0.45.0 → 0.47.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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.47.0] - 2026-10-08
4
+
5
+ ### Changed
6
+
7
+ - **An app with no `fallback` draws a failed route as `<u-empty-state>`**, inside the shell: a route `enter` guard's
8
+ refusal (403) as `no-access`, an address with no route (404) as the new `not-found`, and any other failure (the
9
+ route could not be loaded or rendered) as `error`, titled "Couldn’t open this page" with the error message as its
10
+ description. Before, the router's own error page (a large status code) was drawn — outside this package's look, and
11
+ a refusal looked like an outage. A `fallback` you pass is used as before and replaces the default entirely.
12
+
13
+ ### Added
14
+
15
+ - **`u-empty-state variant="not-found"`** — nothing at this address; the next step is checking the address or going
16
+ back. Not announced as an alert. Default wording in English and Korean (`notFoundTitle`/`notFoundDescription` in
17
+ `modernAppLocale`), and `routeErrorTitle` for the default fallback's error screen.
18
+
19
+ ### Documentation
20
+
21
+ - The empty-state reference's blocked-routes example passes a complete app config (the published 0.46.0 skill
22
+ document left out the required `layout`).
23
+
24
+ ## [0.46.0] - 2026-10-08
25
+
26
+ ### Added
27
+
28
+ - **`u-empty-state variant="no-access"`** — a screen this user may not see (a route `enter` guard returned
29
+ `false`). It is the fourth fact next to `no-data`, `no-results` and `error`, with its own next step (asking for
30
+ access) and its own default wording (English and Korean built in, `noAccessTitle`/`noAccessDescription` in
31
+ `modernAppLocale`). Unlike `error` it is not announced as an alert — a 403 is not an outage.
32
+ - **The router's error classes and route types are exported from this package** — `RouteError`,
33
+ `AccessDeniedError`, `NotFoundError`, `ContentLoadError`, `ContentRenderError`, `OutletMissingError` and the
34
+ `RouteConfig`/`RouteContext`/`FallbackRouteConfig` types. A `fallback.render(ctx)` can tell a blocked screen from a
35
+ missing one (`ctx.error instanceof AccessDeniedError`) without declaring `@iyulab/router` itself.
36
+ - `EmptyStateVariant` type.
37
+
38
+ ### Fixed
39
+
40
+ - The React `IntrinsicElements` entry for `<u-empty-state>` (from `@iyulab/modern-app/react`) accepted only
41
+ `no-data` and `no-results`, so `variant="error"` did not type-check in JSX. It now uses the element's own type.
42
+
3
43
  ## [0.45.0] - 2026-10-08
4
44
 
5
45
  ### Changed (breaking)
package/README.md CHANGED
@@ -38,13 +38,14 @@ await app.load({
38
38
  { path: 'users', render: () => html`<users-page></users-page>` },
39
39
  { path: 'users/:id', render: (ctx) => html`<user-detail .userId=${ctx.params.id}></user-detail>` },
40
40
  ],
41
- fallback: {
42
- render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`,
43
- },
44
41
  theme: { default: 'system' },
45
42
  });
46
43
  ```
47
44
 
45
+ A route that fails is drawn inside the shell as an empty state: a guard's refusal (403) as
46
+ `<u-empty-state variant="no-access">`, an unknown address (404) as `variant="not-found"`, anything else as
47
+ `variant="error"` with the error message. Pass `fallback: { render: (ctx) => … }` to draw your own instead.
48
+
48
49
  ### Icons
49
50
 
50
51
  A menu item's `icon` is a name resolved by `<u-icon>` from `@iyulab/components`:
package/dist/App.js CHANGED
@@ -1,27 +1,28 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
- import t from "i18next";
3
- import { Router as n } from "@iyulab/router";
4
- import { setDefaultBaseUrl as r } from "@iyulab/components/dist/utilities/icons.js";
5
- import { Theme as i } from "@iyulab/components/dist/utilities/Theme.js";
6
- import { Toast as a } from "@iyulab/components/dist/utilities/Toast.js";
7
- import { Locale as o } from "@iyulab/components/dist/utilities/Locale.js";
2
+ import { defaultFallback as t } from "./internals/default-fallback.js";
3
+ import n from "i18next";
4
+ import { Router as r } from "@iyulab/router";
5
+ import { setDefaultBaseUrl as i } from "@iyulab/components/dist/utilities/icons.js";
6
+ import { Theme as a } from "@iyulab/components/dist/utilities/Theme.js";
7
+ import { Toast as o } from "@iyulab/components/dist/utilities/Toast.js";
8
+ import { Locale as s } from "@iyulab/components/dist/utilities/Locale.js";
8
9
  //#region src/App.ts
9
- var s = "\n:where(body) { margin: 0; }\n@media screen {\n :where(body) { width: 100vw; height: 100vh; }\n}\n", c;
10
- function l() {
11
- if (!c) {
10
+ var c = "\n:where(body) { margin: 0; }\n@media screen {\n :where(body) { width: 100vw; height: 100vh; }\n}\n", l;
11
+ function u() {
12
+ if (!l) {
12
13
  if ("adoptedStyleSheets" in document && typeof CSSStyleSheet < "u" && typeof CSSStyleSheet.prototype.replaceSync == "function") {
13
14
  let e = new CSSStyleSheet();
14
- e.replaceSync(s), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], c = e;
15
+ e.replaceSync(c), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], l = e;
15
16
  } else {
16
17
  let e = document.createElement("style");
17
- e.textContent = s, document.head.prepend(e), c = e;
18
+ e.textContent = c, document.head.prepend(e), l = e;
18
19
  }
19
20
  }
20
21
  }
21
- var u = class s {
22
+ var d = class c {
22
23
  constructor() {}
23
24
  static get instance() {
24
- return s._instance ||= new s(), s._instance;
25
+ return c._instance ||= new c(), c._instance;
25
26
  }
26
27
  get config() {
27
28
  return this._config;
@@ -36,22 +37,22 @@ var u = class s {
36
37
  return this._user;
37
38
  }
38
39
  get theme() {
39
- return i;
40
+ return a;
40
41
  }
41
42
  get i18n() {
42
- return t;
43
+ return n;
43
44
  }
44
- async load(a) {
45
- if (this.unload(), this._config = a, await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
46
- for (let e of a.i18n.plugins || []) t.use(e);
47
- await t.init(a.i18n), this.syncLanguage();
45
+ async load(o) {
46
+ if (this.unload(), this._config = o, await a.init(o.theme), o.iconBasepath && i(o.iconBasepath), o.i18n) {
47
+ for (let e of o.i18n.plugins || []) n.use(e);
48
+ await n.init(o.i18n), this.syncLanguage();
48
49
  }
49
- if (a.auth) {
50
- let e = a.root || document.body, t = () => {
51
- this.load(a);
50
+ if (o.auth) {
51
+ let e = o.root || document.body, t = () => {
52
+ this.load(o);
52
53
  }, n;
53
54
  try {
54
- n = await a.auth.me();
55
+ n = await o.auth.me();
55
56
  } catch (e) {
56
57
  n = {
57
58
  status: "unknown",
@@ -59,8 +60,8 @@ var u = class s {
59
60
  };
60
61
  }
61
62
  if (n?.status === "unknown") {
62
- if (!a.auth.renderUnavailable) throw n.error;
63
- let r = a.auth.renderUnavailable({
63
+ if (!o.auth.renderUnavailable) throw n.error;
64
+ let r = o.auth.renderUnavailable({
64
65
  root: e,
65
66
  error: n.error,
66
67
  retry: t
@@ -69,7 +70,7 @@ var u = class s {
69
70
  return;
70
71
  }
71
72
  if (n?.status === "anonymous") {
72
- let n = a.auth.renderLogin({
73
+ let n = o.auth.renderLogin({
73
74
  root: e,
74
75
  onSuccess: t
75
76
  });
@@ -77,32 +78,32 @@ var u = class s {
77
78
  return;
78
79
  }
79
80
  if (n?.status !== "authenticated") throw TypeError("auth.me() must return { status: 'authenticated', user } | { status: 'anonymous' } | { status: 'unknown', error }");
80
- this._user = n.user, await a.auth.onAuthenticated?.(n.user);
81
+ this._user = n.user, await o.auth.onAuthenticated?.(n.user);
81
82
  }
82
- let o = a.root || document.body;
83
- this._layout = await this.createLayout(o, a.layout), this._screen = new e({
84
- element: o,
85
- breakpoints: a.layout.breakpoints || [768, 1024]
83
+ let s = o.root || document.body;
84
+ this._layout = await this.createLayout(s, o.layout), this._screen = new e({
85
+ element: s,
86
+ breakpoints: o.layout.breakpoints || [768, 1024]
86
87
  });
87
- let s = document.createElement("u-outlet");
88
- this._layout.appendChild(s), this._router = new n({
88
+ let c = document.createElement("u-outlet");
89
+ this._layout.appendChild(c), this._router = new r({
89
90
  root: this._layout,
90
- basepath: a.basepath,
91
- mode: a.routerMode,
92
- routes: a.routes,
93
- fallback: a.fallback,
94
- enter: a.enter,
95
- initialLoad: a.initialLoad,
96
- useIntercept: a.useIntercept
91
+ basepath: o.basepath,
92
+ mode: o.routerMode,
93
+ routes: o.routes,
94
+ fallback: o.fallback ?? t,
95
+ enter: o.enter,
96
+ initialLoad: o.initialLoad,
97
+ useIntercept: o.useIntercept
97
98
  });
98
99
  }
99
100
  syncLanguage() {
100
101
  let e = (e) => {
101
- e && e !== "cimode" && (o.set(e), typeof document < "u" && (document.documentElement.lang = e));
102
+ e && e !== "cimode" && (s.set(e), typeof document < "u" && (document.documentElement.lang = e));
102
103
  };
103
- e(t.resolvedLanguage ?? t.language);
104
- let n = (n) => e(t.resolvedLanguage ?? n);
105
- t.on("languageChanged", n), this._languageSync = () => t.off("languageChanged", n);
104
+ e(n.resolvedLanguage ?? n.language);
105
+ let t = (t) => e(n.resolvedLanguage ?? t);
106
+ n.on("languageChanged", t), this._languageSync = () => n.off("languageChanged", t);
106
107
  }
107
108
  unload() {
108
109
  this._languageSync?.(), this._languageSync = void 0, this._gateTeardown &&= (this._gateTeardown(), void 0), this._user = void 0, this._screen &&= (this._screen.destroy(), void 0), this._layout &&= (this._layout.remove(), void 0), this._router &&= (this._router.destroy(), void 0), this._config &&= void 0;
@@ -117,22 +118,22 @@ var u = class s {
117
118
  t === void 0 ? delete r[e] : r[e] = t, n.counts = r;
118
119
  }
119
120
  async notice(e, t) {
120
- await a.notice(e, { ...t });
121
+ await o.notice(e, { ...t });
121
122
  }
122
123
  async info(e, t) {
123
- await a.info(e, { ...t });
124
+ await o.info(e, { ...t });
124
125
  }
125
126
  async warning(e, t) {
126
- await a.warning(e, { ...t });
127
+ await o.warning(e, { ...t });
127
128
  }
128
129
  async success(e, t) {
129
- await a.success(e, { ...t });
130
+ await o.success(e, { ...t });
130
131
  }
131
132
  async error(e, t) {
132
- await a.error(e, { ...t });
133
+ await o.error(e, { ...t });
133
134
  }
134
135
  async createLayout(e, t) {
135
- e === document.body && l();
136
+ e === document.body && u();
136
137
  let n;
137
138
  if (t.type === "sidebar") {
138
139
  let { SidebarLayout: e } = await import("./layouts/SidebarLayout.js"), r = new e();
@@ -142,4 +143,4 @@ var u = class s {
142
143
  }
143
144
  }.instance;
144
145
  //#endregion
145
- export { u as app };
146
+ export { d as app };
@@ -1,5 +1,7 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
2
  type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'actions';
3
+ /** 빈 상태가 전하는 사실 — 다음 행동이 갈린다(`EmptyState.variant` 참조). */
4
+ export type EmptyStateVariant = 'no-data' | 'no-results' | 'error' | 'no-access' | 'not-found';
3
5
  /**
4
6
  * 빈 상태 — 목록·검색 결과가 비었을 때.
5
7
  *
@@ -19,8 +21,17 @@ type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'act
19
21
  * <u-empty-state variant="error" .description=${error.message}>
20
22
  * <u-button slot="actions" @click=${retry}>Try again</u-button>
21
23
  * </u-empty-state>
24
+ *
25
+ * <!-- 라우트 가드가 막았다(403) — 장애가 아니라 권한의 사실이다. -->
26
+ * <u-empty-state variant="no-access"></u-empty-state>
27
+ *
28
+ * <!-- 주소가 아무것도 가리키지 않는다(404) — 다음 행동은 주소 확인 · 돌아가기. -->
29
+ * <u-empty-state variant="not-found"></u-empty-state>
22
30
  * ```
23
31
  *
32
+ * `app.load()` 에 `fallback` 을 주지 않으면 라우팅 실패가 이 요소로 그려진다 — 403 `no-access` · 404 `not-found` ·
33
+ * 그 밖 `error`(오류 메시지가 설명).
34
+ *
24
35
  * ⚠**기본 문구는 영어다** — 이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다.
25
36
  * 한국어 표는 내장돼 있고(언어는 `Locale.set()`), 다른 언어는 `modernAppLocale.register(lang, …)` 로 소비자가 등록한다. 화면별로 덮으려면
26
37
  * `title`·`description` 을 준다.
@@ -32,10 +43,13 @@ type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'act
32
43
  export declare class EmptyState extends StyledElement<ElementParts> {
33
44
  static styles: import('lit').CSSResultGroup[];
34
45
  /**
35
- * `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지 못함.
36
- * 셋은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
46
+ * `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지 못함 /
47
+ * `no-access` = 있지만 이 사용자에게 보여 줄 수 없음(라우트 가드의 거부 · 403) /
48
+ * `not-found` = 주소가 가리키는 화면이 없음(404).
49
+ * 다섯은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도 · 권한 요청 · 주소 확인) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
50
+ * `no-access`·`not-found` 는 알리지 않는다 — 장애가 아니라 화면의 내용이고, 라우트 완료 때 본문으로 가는 포커스가 그것을 읽게 한다.
37
51
  */
38
- variant: 'no-data' | 'no-results' | 'error';
52
+ variant: EmptyStateVariant;
39
53
  /** 제목. 비우면 variant 기본 문구. */
40
54
  title: string;
41
55
  /** 보조 설명. 비우면 variant 기본 문구. */
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./EmptyState.styles.js";
7
7
  import { html as o, nothing as s } from "lit";
8
8
  import { customElement as c, property as l, state as u } from "lit/decorators.js";
@@ -15,20 +15,34 @@ var d = class extends n {
15
15
  this.styles = [super.styles, a];
16
16
  }
17
17
  get defaults() {
18
- let e = r(this.locale || void 0);
19
- return this.variant === "error" ? {
20
- icon: "⚠️",
21
- title: e.errorTitle,
22
- description: e.errorDescription
23
- } : this.variant === "no-results" ? {
24
- icon: "🔍",
25
- title: e.noResultsTitle,
26
- description: e.noResultsDescription
27
- } : {
28
- icon: "📄",
29
- title: e.noDataTitle,
30
- description: e.noDataDescription
31
- };
18
+ let e = i(this.locale || void 0);
19
+ switch (this.variant) {
20
+ case "error": return {
21
+ icon: "⚠️",
22
+ title: e.errorTitle,
23
+ description: e.errorDescription
24
+ };
25
+ case "no-access": return {
26
+ icon: "🔒",
27
+ title: e.noAccessTitle,
28
+ description: e.noAccessDescription
29
+ };
30
+ case "not-found": return {
31
+ icon: "🧭",
32
+ title: e.notFoundTitle,
33
+ description: e.notFoundDescription
34
+ };
35
+ case "no-results": return {
36
+ icon: "🔍",
37
+ title: e.noResultsTitle,
38
+ description: e.noResultsDescription
39
+ };
40
+ default: return {
41
+ icon: "📄",
42
+ title: e.noDataTitle,
43
+ description: e.noDataDescription
44
+ };
45
+ }
32
46
  }
33
47
  render() {
34
48
  let e = this.defaults, t = this.description || e.description;
@@ -44,7 +58,7 @@ var d = class extends n {
44
58
  </div>
45
59
  <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
46
60
  <slot name="actions"
47
- @slotchange=${(e) => this.hasActions = i(e.target)}
61
+ @slotchange=${(e) => this.hasActions = r(e.target)}
48
62
  ></slot>
49
63
  </div>
50
64
  `;
@@ -53,6 +67,6 @@ var d = class extends n {
53
67
  t([l({
54
68
  type: String,
55
69
  reflect: !0
56
- }), e("design:type", String)], d.prototype, "variant", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "title", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "description", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "locale", void 0), t([u(), e("design:type", Object)], d.prototype, "hasActions", void 0), d = t([c("u-empty-state")], d);
70
+ }), e("design:type", Object)], d.prototype, "variant", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "title", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "description", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "locale", void 0), t([u(), e("design:type", Object)], d.prototype, "hasActions", void 0), d = t([c("u-empty-state")], d);
57
71
  //#endregion
58
72
  export { d as EmptyState };
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./MasterDetailLayout.styles.js";
7
7
  import { html as o } from "lit";
8
8
  import { customElement as s, property as c, state as l } from "lit/decorators.js";
@@ -14,7 +14,7 @@ var u = class extends n {
14
14
  super(...e), this.masterSize = "22rem", this.overlayBreakpoint = 760, this.locale = "", this.hasDetail = !1, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.handleDetailClose = () => {
15
15
  this.fire("detail-close", { cancelable: !1 });
16
16
  }, this.handleDetailSlotChange = (e) => {
17
- this.hasDetail = i(e.target);
17
+ this.hasDetail = r(e.target);
18
18
  };
19
19
  }
20
20
  static {
@@ -40,7 +40,7 @@ var u = class extends n {
40
40
  <div class="divider ${this.hasDetail ? "" : "empty"}" part="divider"></div>
41
41
  <div class="detail ${this.hasDetail ? "" : "empty"}" part="detail">
42
42
  <u-button class="detail-close" part="detail-close" appearance="plain"
43
- aria-label=${r(this.locale || void 0).detailClose}
43
+ aria-label=${i(this.locale || void 0).detailClose}
44
44
  @click=${this.handleDetailClose}
45
45
  >
46
46
  <u-icon lib="internal" name="x"></u-icon>
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./PageHeader.styles.js";
7
7
  import { html as o, nothing as s } from "lit";
8
8
  import { customElement as c, property as l, state as u } from "lit/decorators.js";
@@ -16,18 +16,18 @@ var d = class extends n {
16
16
  }
17
17
  render() {
18
18
  return o`
19
- ${this.back ? o`<a class="back" part="back" href=${this.back}>${this.backLabel || r(this.locale || void 0).back}</a>` : s}
19
+ ${this.back ? o`<a class="back" part="back" href=${this.back}>${this.backLabel || i(this.locale || void 0).back}</a>` : s}
20
20
  <div class="heading" part="heading">
21
21
  <div class="eyebrow ${this.eyebrow || this.hasEyebrow ? "" : "empty"}" part="eyebrow">
22
22
  <slot name="eyebrow"
23
- @slotchange=${(e) => this.hasEyebrow = i(e.target)}
23
+ @slotchange=${(e) => this.hasEyebrow = r(e.target)}
24
24
  >${this.eyebrow ?? s}</slot>
25
25
  </div>
26
26
  <div class="title-row">
27
27
  <h1 class="title" part="title">${this.title}</h1>
28
28
  <span class="status ${this.hasStatus ? "" : "empty"}" part="status">
29
29
  <slot name="status"
30
- @slotchange=${(e) => this.hasStatus = i(e.target)}
30
+ @slotchange=${(e) => this.hasStatus = r(e.target)}
31
31
  ></slot>
32
32
  </span>
33
33
  </div>
@@ -35,7 +35,7 @@ var d = class extends n {
35
35
  </div>
36
36
  <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
37
37
  <slot name="actions"
38
- @slotchange=${(e) => this.hasActions = i(e.target)}
38
+ @slotchange=${(e) => this.hasActions = r(e.target)}
39
39
  ></slot>
40
40
  </div>
41
41
  `;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export type * from './types/AppConfigs';
3
3
  export type * from './types/AppOptions';
4
4
  export type * from './types/AuthConfig';
5
5
  export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
6
+ export type { RouteConfig, RouteContext, FallbackRouteConfig } from '@iyulab/router';
7
+ export { RouteError, NotFoundError, AccessDeniedError, ContentLoadError, ContentRenderError, OutletMissingError, } from '@iyulab/router';
6
8
  export type { SidebarIconsConfig, SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, SidebarState, } from './layouts/SidebarLayout.types';
7
9
  export type { SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
8
10
  export { filterSidebarItems } from './layouts/filterSidebarItems.js';
package/dist/index.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
- import { app as t } from "./App.js";
3
- import { filterSidebarItems as n } from "./layouts/filterSidebarItems.js";
4
- import { getDefaultLocale as r, getLocaleStrings as i, modernAppLocale as a, registerLocale as o, setDefaultLocale as s } from "./internals/locale.js";
5
- import { PageHeader as c } from "./components/PageHeader.js";
6
- import { GroupBox as l } from "./components/GroupBox.js";
7
- import { InfoSection as u } from "./components/InfoSection.js";
8
- import { InfoField as d, isBlank as f } from "./components/InfoField.js";
9
- import { EmptyState as p } from "./components/EmptyState.js";
2
+ import { getDefaultLocale as t, getLocaleStrings as n, modernAppLocale as r, registerLocale as i, setDefaultLocale as a } from "./internals/locale.js";
3
+ import { EmptyState as o } from "./components/EmptyState.js";
4
+ import { app as s } from "./App.js";
5
+ import { filterSidebarItems as c } from "./layouts/filterSidebarItems.js";
6
+ import { PageHeader as l } from "./components/PageHeader.js";
7
+ import { GroupBox as u } from "./components/GroupBox.js";
8
+ import { InfoSection as d } from "./components/InfoSection.js";
9
+ import { InfoField as f, isBlank as p } from "./components/InfoField.js";
10
10
  import { TimelineItem as m } from "./components/TimelineItem.js";
11
11
  import { Timeline as h } from "./components/Timeline.js";
12
12
  import { ActionBar as g } from "./components/ActionBar.js";
13
13
  import { MasterDetailLayout as _ } from "./components/MasterDetailLayout.js";
14
14
  import { Wizard as v } from "./components/Wizard.js";
15
15
  import { translate as y } from "./translate.js";
16
+ import { AccessDeniedError as b, ContentLoadError as x, ContentRenderError as S, NotFoundError as C, OutletMissingError as w, RouteError as T } from "@iyulab/router";
16
17
  //#region src/index.ts
17
- var b = t;
18
+ var E = s;
18
19
  //#endregion
19
- export { g as ActionBar, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, _ as MasterDetailLayout, c as PageHeader, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, t as app, b as default, n as filterSidebarItems, r as getDefaultLocale, i as getLocaleStrings, f as isBlank, a as modernAppLocale, o as registerLocale, s as setDefaultLocale, y as translate };
20
+ export { b as AccessDeniedError, g as ActionBar, x as ContentLoadError, S as ContentRenderError, o as EmptyState, u as GroupBox, f as InfoField, d as InfoSection, _ as MasterDetailLayout, C as NotFoundError, w as OutletMissingError, l as PageHeader, T as RouteError, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, s as app, E as default, c as filterSidebarItems, t as getDefaultLocale, n as getLocaleStrings, p as isBlank, r as modernAppLocale, i as registerLocale, a as setDefaultLocale, y as translate };
@@ -0,0 +1,16 @@
1
+ import { FallbackRouteConfig, RouteError } from '@iyulab/router';
2
+ import { EmptyStateVariant } from '../components/EmptyState.js';
3
+ /**
4
+ * `app.load()` 에 `fallback` 을 주지 않은 앱의 라우팅 실패 화면 — 앱 프레임워크의 빈 상태 어휘로 그린다.
5
+ *
6
+ * 종전에는 router 의 `UErrorPage`(큰 상태 코드 화면)가 그려졌다. 셸·빈 상태 어휘를 가진 프레임워크에서 오류만
7
+ * 남의 외형이었고, 권한 거부(403)도 «장애» 처럼 보였다.
8
+ *
9
+ * - 403 → `no-access`(라우트 가드의 거부) · 404 → `not-found` · 그 밖 → `error`(오류 메시지가 설명).
10
+ * - 판정은 클래스가 아니라 **코드**로 한다 — `AccessDeniedError`·`NotFoundError` 가 403·404 를 싣고, 라우트가 던진
11
+ * `{ status: 404 }` 류도 router 가 같은 코드의 `RouteError` 로 감싼다. 진단 코드(`OUTLET_MISSING` 등)는 화면에
12
+ * 코드로 내지 않고 메시지를 설명으로 쓴다.
13
+ * - 소비자가 `fallback` 을 주면 그것이 그대로 이긴다(이 모듈은 쓰이지 않는다).
14
+ */
15
+ export declare function variantOf(error: Pick<RouteError, 'code'>): EmptyStateVariant;
16
+ export declare const defaultFallback: FallbackRouteConfig;
@@ -0,0 +1,16 @@
1
+ import { getLocaleStrings as e } from "./locale.js";
2
+ import { EmptyState as t } from "../components/EmptyState.js";
3
+ //#region src/internals/default-fallback.ts
4
+ function n(e) {
5
+ switch (String(e.code)) {
6
+ case "403": return "no-access";
7
+ case "404": return "not-found";
8
+ default: return "error";
9
+ }
10
+ }
11
+ var r = { render: (r) => {
12
+ let i = new t();
13
+ return i.variant = n(r.error), i.variant === "error" && (i.title = e().routeErrorTitle, i.description = r.error.message), i;
14
+ } };
15
+ //#endregion
16
+ export { r as defaultFallback, n as variantOf };
@@ -10,6 +10,15 @@ export interface ModernAppLocaleStrings {
10
10
  /** Empty state — the list could not be loaded (`variant="error"`). */
11
11
  errorTitle: string;
12
12
  errorDescription: string;
13
+ /** Empty state — the screen exists but this user may not see it (`variant="no-access"` — a route guard said no). */
14
+ noAccessTitle: string;
15
+ noAccessDescription: string;
16
+ /** Empty state — the address points at nothing (`variant="not-found"` — the router found no route, a 404). */
17
+ notFoundTitle: string;
18
+ notFoundDescription: string;
19
+ /** Default route fallback — the title when a route failed for another reason (load or render failure). The
20
+ * `error` variant's own default title speaks of a list. */
21
+ routeErrorTitle: string;
13
22
  /** Accessible label for an overlay-mode close button (master-detail layout, sidebar layout overlay). */
14
23
  detailClose: string;
15
24
  /** Sidebar layout — accessible label for the mobile-header menu toggle button. */
@@ -8,6 +8,11 @@ var t = {
8
8
  noResultsDescription: "Try changing your search terms or filters.",
9
9
  errorTitle: "Couldn’t load this list",
10
10
  errorDescription: "Something went wrong while loading. Try again.",
11
+ noAccessTitle: "You don’t have access",
12
+ noAccessDescription: "Ask an administrator for access if you need it.",
13
+ notFoundTitle: "Page not found",
14
+ notFoundDescription: "Check the address, or go back to the previous screen.",
15
+ routeErrorTitle: "Couldn’t open this page",
11
16
  detailClose: "Close",
12
17
  toggleMobileMenu: "Toggle menu",
13
18
  toggleSidebar: "Toggle sidebar",
@@ -26,6 +31,11 @@ n.register("en", t), n.register("ko", {
26
31
  noResultsDescription: "검색어나 필터를 바꿔 보세요.",
27
32
  errorTitle: "목록을 불러오지 못했습니다",
28
33
  errorDescription: "불러오는 중 문제가 생겼습니다. 다시 시도해 주세요.",
34
+ noAccessTitle: "접근 권한이 없습니다",
35
+ noAccessDescription: "필요하면 관리자에게 권한을 요청하세요.",
36
+ notFoundTitle: "페이지를 찾을 수 없습니다",
37
+ notFoundDescription: "주소를 확인하거나 이전 화면으로 돌아가 주세요.",
38
+ routeErrorTitle: "화면을 열지 못했습니다",
29
39
  detailClose: "닫기",
30
40
  toggleMobileMenu: "메뉴 열고 닫기",
31
41
  toggleSidebar: "사이드바 열고 닫기",
@@ -62,6 +72,11 @@ function l(e) {
62
72
  noResultsDescription: t("noResultsDescription"),
63
73
  errorTitle: t("errorTitle"),
64
74
  errorDescription: t("errorDescription"),
75
+ noAccessTitle: t("noAccessTitle"),
76
+ noAccessDescription: t("noAccessDescription"),
77
+ notFoundTitle: t("notFoundTitle"),
78
+ notFoundDescription: t("notFoundDescription"),
79
+ routeErrorTitle: t("routeErrorTitle"),
65
80
  detailClose: t("detailClose"),
66
81
  toggleMobileMenu: t("toggleMobileMenu"),
67
82
  toggleSidebar: t("toggleSidebar"),
@@ -1,10 +1,10 @@
1
- import { app as e } from "../App.js";
2
- import { filterSidebarItems as t } from "./filterSidebarItems.js";
3
- import n from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
4
- import r from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
5
- import { StyledElement as i } from "../internals/StyledElement.js";
6
- import { getLocaleStrings as a } from "../internals/locale.js";
7
- import { slotHasContent as o } from "../internals/slotted.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
+ import { app as a } from "../App.js";
7
+ import { filterSidebarItems as o } from "./filterSidebarItems.js";
8
8
  import "../components/SidebarSection.js";
9
9
  import { DEFAULT_NAV_ICON as s } from "../internals/nav-icon.js";
10
10
  import "../components/SidebarLink.js";
@@ -74,14 +74,14 @@ function j(e, t) {
74
74
  }
75
75
  return !1;
76
76
  }
77
- var M = class extends i {
77
+ var M = class extends n {
78
78
  static {
79
79
  S = this;
80
80
  }
81
- constructor(...t) {
82
- super(...t), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.routeLoading = !1, this.mainTabStop = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.currentPath = null, this.unsizedWarnKey = `unsized:${++w}`, this.isMatchedLink = (e) => this.currentPath === null || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.currentPath, window.location.origin)), this.handleToggleButtonClick = () => {
83
- let t = this.screenSize ?? e.screen ?? "large";
84
- t === "large" ? this.state = this.state === "default" ? "slim" : "default" : t === "medium" ? this.state = this.state === "slim" ? "modal" : "slim" : t === "small" ? this.state = this.state === "mobile" ? "mobile-open" : "mobile" : console.warn("Unknown screen size:", t);
81
+ constructor(...e) {
82
+ super(...e), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.routeLoading = !1, this.mainTabStop = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.currentPath = null, this.unsizedWarnKey = `unsized:${++w}`, this.isMatchedLink = (e) => this.currentPath === null || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.currentPath, window.location.origin)), this.handleToggleButtonClick = () => {
83
+ let e = this.screenSize ?? a.screen ?? "large";
84
+ e === "large" ? this.state = this.state === "default" ? "slim" : "default" : e === "medium" ? this.state = this.state === "slim" ? "modal" : "slim" : e === "small" ? this.state = this.state === "mobile" ? "mobile-open" : "mobile" : console.warn("Unknown screen size:", e);
85
85
  }, this.handleBackdropClick = () => {
86
86
  this.state = "slim";
87
87
  }, this.handleRouteBegin = (e) => {
@@ -110,14 +110,14 @@ var M = class extends i {
110
110
  this.routeLoading = !1, this.progressBarEl.status = "default";
111
111
  }, 300);
112
112
  }, this.handleOverlaySlotChange = (e) => {
113
- let t = o(e.target);
113
+ let t = r(e.target);
114
114
  if (t && !this.hasOverlay) {
115
115
  let e = A();
116
116
  this.overlayTrigger = e && e !== document.body ? e : null;
117
117
  }
118
118
  this.hasOverlay = t;
119
119
  }, this.handleNoticeSlotChange = (e) => {
120
- this.hasNotice = o(e.target), this.handleMainSlotChange();
120
+ this.hasNotice = r(e.target), this.handleMainSlotChange();
121
121
  }, this.handleOverlayEscape = (e) => {
122
122
  let t = this.shadowRoot?.querySelector(".overlay");
123
123
  return !this.hasOverlay || !t || !e.composedPath().includes(t) ? !1 : (this.handleOverlayClose(), !0);
@@ -193,7 +193,7 @@ var M = class extends i {
193
193
  return this.config ? u`
194
194
  <!-- 반복 영역 건너뛰기(WCAG 2.4.1) — 셸의 첫 Tab. 포커스될 때만 보인다. -->
195
195
  <a class="skip-link" part="skip-link" href="#" @click=${this.handleSkipLinkClick}>
196
- ${a(this.locale || void 0).skipToContent}
196
+ ${i(this.locale || void 0).skipToContent}
197
197
  </a>
198
198
 
199
199
  <!-- Mobile Header -->
@@ -203,7 +203,7 @@ var M = class extends i {
203
203
  ${this.config.title}
204
204
  </span>
205
205
  <u-button class="toggler" appearance="plain"
206
- aria-label=${a(this.locale || void 0).toggleMobileMenu}
206
+ aria-label=${i(this.locale || void 0).toggleMobileMenu}
207
207
  @click=${this.handleToggleButtonClick}>
208
208
  <u-icon
209
209
  lib=${this.icon("lib")}
@@ -221,7 +221,7 @@ var M = class extends i {
221
221
  ${this.config.title}
222
222
  </span>
223
223
  <u-button class="toggler" appearance="plain"
224
- aria-label=${a(this.locale || void 0).toggleSidebar}
224
+ aria-label=${i(this.locale || void 0).toggleSidebar}
225
225
  @click=${this.handleToggleButtonClick}>
226
226
  <u-icon
227
227
  lib=${this.icon("lib")}
@@ -233,12 +233,12 @@ var M = class extends i {
233
233
  <!-- Sidebar Navigation Menu -->
234
234
  <nav class="sidebar-main" part="sidebar-main" scrollable
235
235
  aria-label=${this.config.mainAriaLabel ?? d}>
236
- ${_(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
236
+ ${_(o(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
237
237
  </nav>
238
238
 
239
239
  <!-- Sidebar Footer -->
240
240
  <div class="sidebar-footer" part="sidebar-footer">
241
- ${_(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
241
+ ${_(o(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
242
242
  </div>
243
243
  </aside>
244
244
 
@@ -247,7 +247,7 @@ var M = class extends i {
247
247
  <main class="main" part="main" scrollable tabindex=${this.mainTabStop ? "0" : "-1"} @keydown=${this._handleMainKeydown}>
248
248
  <u-progress-bar part="progress" class=${this.routeLoading ? "loading" : ""}
249
249
  aria-hidden=${this.routeLoading ? d : "true"}
250
- aria-label=${a(this.locale || void 0).pageLoading}></u-progress-bar>
250
+ aria-label=${i(this.locale || void 0).pageLoading}></u-progress-bar>
251
251
 
252
252
  <div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
253
253
  <div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
@@ -261,7 +261,7 @@ var M = class extends i {
261
261
 
262
262
  <div class="overlay ${this.hasOverlay ? "" : "empty"}" part="overlay">
263
263
  <u-button class="overlay-close" part="overlay-close" appearance="plain"
264
- aria-label=${a(this.locale || void 0).detailClose}
264
+ aria-label=${i(this.locale || void 0).detailClose}
265
265
  @click=${this.handleOverlayClose}>
266
266
  <u-icon lib=${this.icon("lib")} name=${this.icon("overlayClose")}></u-icon>
267
267
  </u-button>
@@ -470,9 +470,9 @@ var M = class extends i {
470
470
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
471
471
  }
472
472
  };
473
- r([p({
473
+ t([p({
474
474
  type: String,
475
475
  reflect: !0
476
- }), n("design:type", Object)], M.prototype, "state", void 0), r([p({ type: Object }), n("design:type", Object)], M.prototype, "config", void 0), r([p({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], M.prototype, "counts", void 0), r([p({ type: String }), n("design:type", Object)], M.prototype, "locale", void 0), r([m("u-progress-bar"), n("design:type", Object)], M.prototype, "progressBarEl", void 0), r([h(), n("design:type", Object)], M.prototype, "hasOverlay", void 0), r([h(), n("design:type", Object)], M.prototype, "openMenu", void 0), r([h(), n("design:type", Object)], M.prototype, "hasNotice", void 0), r([h(), n("design:type", Object)], M.prototype, "routeLoading", void 0), r([h(), n("design:type", Object)], M.prototype, "mainTabStop", void 0), r([h(), n("design:type", Object)], M.prototype, "currentPath", void 0), M = S = r([f("u-sidebar-layout")], M);
476
+ }), e("design:type", Object)], M.prototype, "state", void 0), t([p({ type: Object }), e("design:type", Object)], M.prototype, "config", void 0), t([p({ type: Object }), e("design:type", typeof Record > "u" ? Object : Record)], M.prototype, "counts", void 0), t([p({ type: String }), e("design:type", Object)], M.prototype, "locale", void 0), t([m("u-progress-bar"), e("design:type", Object)], M.prototype, "progressBarEl", void 0), t([h(), e("design:type", Object)], M.prototype, "hasOverlay", void 0), t([h(), e("design:type", Object)], M.prototype, "openMenu", void 0), t([h(), e("design:type", Object)], M.prototype, "hasNotice", void 0), t([h(), e("design:type", Object)], M.prototype, "routeLoading", void 0), t([h(), e("design:type", Object)], M.prototype, "mainTabStop", void 0), t([h(), e("design:type", Object)], M.prototype, "currentPath", void 0), M = S = t([f("u-sidebar-layout")], M);
477
477
  //#endregion
478
478
  export { M as SidebarLayout };
package/dist/react.d.ts CHANGED
@@ -3,6 +3,7 @@ import { EventName } from '@lit/react';
3
3
  import { SidebarLayout as SidebarLayoutElement, SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
4
4
  import { Wizard as WizardElement, WizardEventMap } from './components/Wizard.js';
5
5
  import { GroupBoxLevel } from './components/GroupBox.js';
6
+ import { EmptyStateVariant } from './components/EmptyState.js';
6
7
  import { TimelineItemColor } from './components/TimelineItem.js';
7
8
  import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
8
9
  export { ScreenObserver } from './internals/ScreenObserver.js';
@@ -37,7 +38,8 @@ declare module 'react' {
37
38
  sticky?: boolean;
38
39
  };
39
40
  'u-empty-state': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
40
- variant?: 'no-data' | 'no-results';
41
+ /** 요소의 유니온을 그대로 쓴다 — 손으로 옮긴 유니온이 `'error'` 를 빠뜨린 채 남아 있었다. */
42
+ variant?: EmptyStateVariant;
41
43
  title?: string;
42
44
  description?: string;
43
45
  locale?: string;
@@ -51,6 +51,8 @@ export interface AppConfig {
51
51
  routes?: RouteConfig[];
52
52
  /**
53
53
  * 라우팅 실패 시 대체 컨텐츠 설정
54
+ * - 주지 않으면 `<u-empty-state>` 로 그린다 — 403(가드 거부) `no-access` · 404 `not-found` ·
55
+ * 그 밖 `error`(오류 메시지가 설명). 주면 이것이 그대로 쓰인다.
54
56
  */
55
57
  fallback?: FallbackRouteConfig;
56
58
  /**
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.45.0",
4
+ "version": "0.47.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -190,8 +190,14 @@ interface FallbackRouteConfig {
190
190
  }
191
191
  ```
192
192
 
193
- `RouteError` is importable from `@iyulab/router` (`code`/`original`/`timestamp` alongside the
194
- inherited `message`) — always populated when the fallback fires.
193
+ `RouteError` (`code`/`original`/`timestamp` alongside the inherited `message`) is always populated
194
+ when the fallback fires. It and its subclasses (`AccessDeniedError` — an `enter` guard returned `false`,
195
+ code 403 · `NotFoundError` · `ContentLoadError` · `ContentRenderError` · `OutletMissingError`) are
196
+ exported from `@iyulab/modern-app` — tell a blocked screen from a missing one with
197
+ `ctx.error instanceof AccessDeniedError` and render it as `<u-empty-state variant="no-access">`.
198
+
199
+ Without a `fallback`, `app.load()` draws `<u-empty-state>` itself: 403 → `no-access` · 404 → `not-found` ·
200
+ anything else → `error` (title "Couldn’t open this page", the error message as description).
195
201
 
196
202
  ---
197
203
 
@@ -8,6 +8,8 @@ Shown when a list or a search comes back with nothing.
8
8
  *nothing has been created yet* and the next action is **create**. The second means *nothing
9
9
  matches these conditions* and the next action is **change the filter**. Show the same wording
10
10
  for both and a user with a filter still applied reads it as *"my data disappeared"*.
11
+ The same holds for a screen this user may not see: it is neither empty nor broken, and its next
12
+ action is **asking for access**.
11
13
 
12
14
  ```html
13
15
  <u-empty-state variant="no-data" title="No orders yet">
@@ -20,6 +22,12 @@ for both and a user with a filter still applied reads it as *"my data disappeare
20
22
  <u-empty-state variant="error" description="Request failed (503)">
21
23
  <u-button slot="actions">Try again</u-button>
22
24
  </u-empty-state>
25
+
26
+ <!-- A route guard said no (403) — not an outage, so it is not announced as an alert. -->
27
+ <u-empty-state variant="no-access"></u-empty-state>
28
+
29
+ <!-- The address points at nothing (404) — the next step is checking the address or going back. -->
30
+ <u-empty-state variant="not-found"></u-empty-state>
23
31
  ```
24
32
 
25
33
  ## Slots
@@ -33,7 +41,7 @@ for both and a user with a filter still applied reads it as *"my data disappeare
33
41
 
34
42
  | Property | Type | Default | Reflect | Description |
35
43
  |----------|------|---------|---------|-------------|
36
- | `variant` | `'no-data'\|'no-results'\|'error'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title |
44
+ | `variant` | `'no-data'\|'no-results'\|'error'\|'no-access'\|'not-found'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title; `no-access` (this user may not see it) and `not-found` (nothing at this address) do not |
37
45
  | `title` | `string` | `''` | | Override the default title |
38
46
  | `description` | `string` | `''` | | Override the default description |
39
47
  | `locale` | `string` | `''` | | Locale tag override for built-in strings |
@@ -63,3 +71,45 @@ for both and a user with a filter still applied reads it as *"my data disappeare
63
71
  ⚠ Default wording is **English**, with Korean built in — choose the language with `Locale.set()` of
64
72
  `@iyulab/components`, register others with `modernAppLocale.register(lang, …)`, or pass
65
73
  `title`/`description` per screen.
74
+
75
+ ## Blocked and missing routes
76
+
77
+ **An app that gives `app.load()` no `fallback` gets this element by default**: a route `enter` guard
78
+ that returns `false` (`AccessDeniedError`, code 403) shows `no-access`, an address with no route
79
+ (`NotFoundError`, 404) shows `not-found`, and any other failure (the route could not be loaded or
80
+ rendered) shows `error` with the page-level title "Couldn’t open this page" and the error message as
81
+ its description. The screen is drawn inside the shell, so the sidebar stays.
82
+
83
+ ```typescript
84
+ import { app } from '@iyulab/modern-app';
85
+ import { html } from 'lit';
86
+
87
+ const canAudit = () => false; // your permission check
88
+
89
+ await app.load({
90
+ layout: { type: 'sidebar' },
91
+ routes: [{ path: '/audit', enter: canAudit, render: () => html`<audit-screen></audit-screen>` }],
92
+ // no fallback — /audit shows <u-empty-state variant="no-access">
93
+ });
94
+ ```
95
+
96
+ Give a `fallback` to draw something else — it replaces the default entirely. The error classes are
97
+ exported from `@iyulab/modern-app`, so it can tell a blocked screen from a missing one without
98
+ depending on the router directly:
99
+
100
+ ```typescript
101
+ import { app, AccessDeniedError } from '@iyulab/modern-app';
102
+ import { html } from 'lit';
103
+
104
+ const canAudit = () => false; // your permission check
105
+
106
+ await app.load({
107
+ layout: { type: 'sidebar' },
108
+ routes: [{ path: '/audit', enter: canAudit, render: () => html`<audit-screen></audit-screen>` }],
109
+ fallback: {
110
+ render: (ctx) => ctx.error instanceof AccessDeniedError
111
+ ? html`<u-empty-state variant="no-access"></u-empty-state>`
112
+ : html`<u-empty-state variant="error" .description=${ctx.error.message}></u-empty-state>`,
113
+ },
114
+ });
115
+ ```