@iyulab/modern-app 0.3.7 → 0.6.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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-07-03
4
+
5
+ ### Added
6
+ - **`AppConfig.auth`** — 부팅 인증 게이트. 지정하면 앱 셸(레이아웃·라우터)을 만들기 전에 `me()` 로 세션을 판정하여, 인증 시 셸을 로드하고 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 (재)로드되어 셸이 나타난다. `onAuthenticated(user)` 로 셸 구성 직전 훅(권한 set 등)을 제공한다.
7
+ - 프레임워크는 인증 **오케스트레이션**(판정→분기→재로드)만 소유. 세션 조회/로그인 HTTP·사용자/권한 형태·세션-중 401 은 앱/`@iyulab/enterprise`(`createAuthClient`/`createODataService`)가 소유한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 부팅 게이트를 표준화.
8
+ - **`app.user`** getter — 인증 게이트 사용 시 인증된 현재 사용자(미인증/미사용이면 `undefined`).
9
+ - **사이드바 네이티브 권한 메뉴 필터** — 모든 메뉴 항목(link/section/group/button/html)에 `requirePermission?`/`requireAnyPermission?` 지원(공통 `SidebarPermissionGuard`). `SidebarLayoutConfig.hasPermission` 판정 함수를 주면 만족하지 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화. 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 도 export.
10
+
11
+ ### Notes
12
+ - `auth`/`hasPermission` 미지정 시 동작은 완전히 하위호환(게이트·필터 없이 기존대로 셸 로드).
13
+
14
+ ## [0.5.0] - 2026-07-02
15
+
16
+ ### Added
17
+ - `AppConfig.enter` — global route guard, forwarded to the underlying `@iyulab/router` `Router`. Previously `@iyulab/router` already supported this via `RouterConfig.enter`, but `App.load()` never passed it through, so the global guard path was unreachable from modern-app. Return a `string` to redirect, `false` to cancel (403), `true`/nothing to proceed. See [docs/routing.md#authentication--guards](./docs/routing.md#authentication--guards).
18
+ - `AppConfig.initialLoad` / `AppConfig.useIntercept` — forwarded to `Router` alongside `enter` (previously also unreachable; `initialLoad: false` is needed for guard unit tests that drive navigation explicitly).
19
+ - Vitest + happy-dom test infrastructure (`npm test`) — first automated test suite for this package, covering `AppConfig.enter` redirect/cancel behavior.
20
+
21
+ ### Changed
22
+ - `package.json` `scripts`: `"test"` now runs `vitest run` (was `vite`, which only started the dev server). The old behavior is available as `"start"`.
23
+
24
+ ## [0.4.0] - 2026-07-02
25
+
26
+ ### Added
27
+ - `SidebarLayoutConfig.logo` now accepts an image (`{ src, alt?, href? }`) or a custom render function (`(state) => TemplateResult | HTMLElement | string`), in addition to the existing icon-name string. Logo click navigates to `/` by default, or to `href` when given on the image variant.
28
+
29
+ ### Fixed
30
+ - `README.md`/`docs/`/`skills/` previously documented `logo` as accepting an image URL string (e.g. `'/assets/logo.svg'`), but the implementation only ever rendered it as a `u-icon` name — an image URL string silently failed to render an image. Docs now use the image variant (`{ src, alt }`) for that case.
31
+
32
+ ## [0.3.7] - 2026-06-09
33
+
34
+ ### Fixed
35
+ - `SidebarLayout`: main-content keyboard scroll shortcuts (Space/arrows/Home/End/PageUp/PageDown) were swallowing input inside editable elements (input/textarea/select/contenteditable/ARIA textbox) — now skipped via `composedPath()`-based detection
36
+
37
+ ## [0.3.6] - 2026-05-21
38
+
39
+ ### Added
40
+ - `SidebarLayout`: keyboard scroll support for `.main` content area (`tabindex="-1"` + `@keydown` handler) — WCAG 2.1 SC 2.1.1 accessibility fix, focus moves to `.main` on route change
41
+
3
42
  ## [0.3.5] - 2026-04-07
4
43
 
5
44
  ### Changed
package/README.md CHANGED
@@ -26,7 +26,7 @@ await app.load({
26
26
  basepath: '/',
27
27
  layout: {
28
28
  type: 'sidebar',
29
- logo: '/assets/logo.svg',
29
+ logo: { src: '/assets/logo.svg', alt: 'My App' },
30
30
  title: 'My App',
31
31
  main: [
32
32
  { type: 'link', icon: 'home', label: 'Home', href: '/' },
@@ -109,12 +109,67 @@ import { translate } from 'lit-i18n';
109
109
  html`<p>${translate('common::greeting')}</p>`;
110
110
  ```
111
111
 
112
+ ### 부팅 인증 게이트 (`auth`)
113
+
114
+ 셸을 만들기 전에 세션을 판정한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 "me 조회 → 미인증이면 로그인, 인증이면 앱 로드" 게이트를 표준화한다. 세션 조회/로그인 HTTP 는 `@iyulab/enterprise` 의 `createAuthClient` 가, 세션-중 401 은 `createODataService` 의 `onUnauthorized` 가 담당한다(프레임워크는 오케스트레이션만 소유).
115
+
116
+ ```typescript
117
+ import { createAuthClient, setPermissions } from '@iyulab/enterprise';
118
+
119
+ const auth = createAuthClient<User, Cred>({ meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout' });
120
+
121
+ await app.load({
122
+ layout: { type: 'sidebar', /* ... */ },
123
+ auth: {
124
+ me: () => auth.fetchMe(), // null → 미인증 → renderLogin
125
+ renderLogin: ({ root, onSuccess }) => renderLoginPage(root, auth, onSuccess),
126
+ onAuthenticated: (user) => setPermissions((user as User).Permissions),
127
+ },
128
+ routes: [ /* ... */ ],
129
+ });
130
+
131
+ app.user; // 인증된 현재 사용자(미인증/미사용 시 undefined)
132
+ ```
133
+
134
+ - `me()` 가 값을 반환하면 셸 로드, `null`/`undefined` 면 `renderLogin({ root, onSuccess })`.
135
+ - 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 재로드되어 셸이 나타나고 로그인 UI 는 정리된다.
136
+ - `auth` 미지정 시 완전히 하위호환(게이트 없이 기존대로 로드).
137
+
138
+ ### 권한 기반 메뉴 필터
139
+
140
+ 모든 사이드바 메뉴 항목에 `requirePermission`/`requireAnyPermission` 를 달고, 레이아웃에 `hasPermission` 판정 함수를 주면 권한 없는 항목이 숨겨진다. 항목이 모두 걸러진 section/group 은 통째로 숨는다. 소비앱이 손으로 짜던 `filterMenu` 를 대체한다.
141
+
142
+ ```typescript
143
+ import { hasPermission } from '@iyulab/enterprise';
144
+
145
+ await app.load({
146
+ layout: {
147
+ type: 'sidebar',
148
+ hasPermission, // enterprise 권한 store 판정
149
+ main: [
150
+ { type: 'link', icon: 'house', label: '홈', href: '/' },
151
+ { type: 'link', icon: 'gear', label: '설정', href: '/settings', requirePermission: 'admin.maintenance' },
152
+ {
153
+ type: 'section', title: '주문',
154
+ items: [
155
+ { type: 'link', label: '주문 목록', href: '/orders', requireAnyPermission: ['orders.read', 'orders.write'] },
156
+ ],
157
+ },
158
+ ],
159
+ },
160
+ auth: { /* ... */ },
161
+ });
162
+ ```
163
+
164
+ - `hasPermission` 미지정 시 필터링하지 않는다(모든 항목 표시 — 하위호환).
165
+ - 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 를 직접 재사용할 수도 있다.
166
+
112
167
  ## Documentation
113
168
 
114
169
  | Guide | Description |
115
170
  |-------|-------------|
116
171
  | [getting-started.md](./docs/getting-started.md) | Bootstrap, architecture, entry point setup |
117
- | [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress |
172
+ | [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress, auth guards |
118
173
  | [layout.md](./docs/layout.md) | Sidebar layout, all menu item types, responsive behaviour |
119
174
  | [theme.md](./docs/theme.md) | Theme init, runtime switching, CSS tokens |
120
175
  | [notifications.md](./docs/notifications.md) | Toast methods and options |
package/dist/App.d.ts CHANGED
@@ -12,6 +12,8 @@ declare class App {
12
12
  private _layout?;
13
13
  private _router?;
14
14
  private _screen?;
15
+ private _user?;
16
+ private _loginTeardown?;
15
17
  private constructor();
16
18
  /** 싱글톤 인스턴스 반환 */
17
19
  static get instance(): App;
@@ -21,6 +23,8 @@ declare class App {
21
23
  get router(): Router | undefined;
22
24
  /** 화면 크기 반환 */
23
25
  get screen(): ScreenSize | undefined;
26
+ /** 인증 게이트(`auth`) 사용 시 인증된 현재 사용자. 미인증/미사용이면 undefined. */
27
+ get user(): unknown;
24
28
  /** 스타일 테마 관리 유틸리티 객체 반환 */
25
29
  get theme(): typeof Theme;
26
30
  /** 다국어 로컬라이저(i18next) 반환 */
package/dist/App.js CHANGED
@@ -18,6 +18,9 @@ var o = class o {
18
18
  get screen() {
19
19
  return this._screen?.get();
20
20
  }
21
+ get user() {
22
+ return this._user;
23
+ }
21
24
  get theme() {
22
25
  return i;
23
26
  }
@@ -25,7 +28,21 @@ var o = class o {
25
28
  return t;
26
29
  }
27
30
  async load(a) {
28
- if (this.unload(), this._config = a, await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
31
+ if (this.unload(), this._config = a, a.auth) {
32
+ let e = await a.auth.me();
33
+ if (e == null) {
34
+ let e = a.root || document.body, t = a.auth.renderLogin({
35
+ root: e,
36
+ onSuccess: () => {
37
+ this.load(a);
38
+ }
39
+ });
40
+ t && (this._loginTeardown = t);
41
+ return;
42
+ }
43
+ this._user = e, await a.auth.onAuthenticated?.(e);
44
+ }
45
+ if (await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
29
46
  for (let e of a.i18n.plugins || []) t.use(e);
30
47
  await t.init(a.i18n);
31
48
  }
@@ -39,11 +56,14 @@ var o = class o {
39
56
  root: this._layout,
40
57
  basepath: a.basepath,
41
58
  routes: a.routes,
42
- fallback: a.fallback
59
+ fallback: a.fallback,
60
+ enter: a.enter,
61
+ initialLoad: a.initialLoad,
62
+ useIntercept: a.useIntercept
43
63
  });
44
64
  }
45
65
  unload() {
46
- 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;
66
+ this._loginTeardown &&= (this._loginTeardown(), 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;
47
67
  }
48
68
  navigate(e) {
49
69
  this._router?.go(e);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.138.0/helpers/esm/decorate.js
2
2
  function e(e, t, n, r) {
3
3
  var i = arguments.length, a = i < 3 ? t : r === null ? r = Object.getOwnPropertyDescriptor(t, n) : r, o;
4
4
  if (typeof Reflect == "object" && typeof Reflect.decorate == "function") a = Reflect.decorate(e, t, n, r);
@@ -6,4 +6,4 @@ function e(e, t, n, r) {
6
6
  return i > 3 && a && Object.defineProperty(t, n, a), a;
7
7
  }
8
8
  //#endregion
9
- export { e as __decorate };
9
+ export { e as default };
@@ -1,6 +1,6 @@
1
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.138.0/helpers/esm/decorateMetadata.js
2
2
  function e(e, t) {
3
3
  if (typeof Reflect == "object" && typeof Reflect.metadata == "function") return Reflect.metadata(e, t);
4
4
  }
5
5
  //#endregion
6
- export { e as __decorateMetadata };
6
+ export { e as default };
@@ -1,9 +1,10 @@
1
1
  import { DirectiveResult } from 'lit/directive.js';
2
2
  import { StyledElement, StyleMap } from '../internals/StyledElement.js';
3
+ import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
3
4
  /** 버튼 항목 부분 */
4
5
  type ElementParts = 'host' | 'base' | 'icon' | 'label';
5
6
  /** 버튼 항목 구성 */
6
- export interface SidebarButtonConfig {
7
+ export interface SidebarButtonConfig extends SidebarPermissionGuard {
7
8
  type: 'button';
8
9
  icon?: string;
9
10
  label?: string | DirectiveResult;
@@ -1,5 +1,5 @@
1
- import { __decorateMetadata as e } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
2
- import { __decorate as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
4
  import { styles as r } from "./SidebarButton.styles.js";
5
5
  import { html as i } from "lit";
@@ -31,3 +31,4 @@ t([o({
31
31
  reflect: !0
32
32
  }), e("design:type", Object)], s.prototype, "compact", void 0), t([o({ type: String }), e("design:type", String)], s.prototype, "icon", void 0), t([o({ type: String }), e("design:type", Object)], s.prototype, "label", void 0), s = t([a("u-sidebar-button")], s);
33
33
  //#endregion
34
+ export { s as SidebarButton };
@@ -1,9 +1,10 @@
1
1
  import { DirectiveResult } from 'lit/directive.js';
2
2
  import { StyledElement, StyleMap } from '../internals/StyledElement.js';
3
3
  import { SidebarLinkConfig } from './SidebarLink.js';
4
+ import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
4
5
  type ElementParts = 'host' | 'header' | 'icon' | 'label' | 'caret' | 'items';
5
6
  /** 그룹: 하위 링크들 묶음 */
6
- export interface SidebarGroupConfig {
7
+ export interface SidebarGroupConfig extends SidebarPermissionGuard {
7
8
  type: 'group';
8
9
  /** 기본 접힘 상태 */
9
10
  collapsed?: boolean;
@@ -1,5 +1,5 @@
1
- import { __decorateMetadata as e } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
2
- import { __decorate as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
4
  import { SidebarLink as r } from "./SidebarLink.js";
5
5
  import { styles as i } from "./SidebarGroup.styles.js";
@@ -53,3 +53,4 @@ t([s({
53
53
  reflect: !0
54
54
  }), e("design:type", Boolean)], c.prototype, "collapsed", void 0), t([s({ type: String }), e("design:type", String)], c.prototype, "icon", void 0), t([s({ type: String }), e("design:type", Object)], c.prototype, "label", void 0), c = t([o("u-sidebar-group")], c);
55
55
  //#endregion
56
+ export { c as SidebarGroup };
@@ -1,8 +1,9 @@
1
1
  import { DirectiveResult } from 'lit/directive.js';
2
2
  import { StyledElement, StyleMap } from '../internals/StyledElement.js';
3
+ import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
3
4
  type ElementParts = 'host' | 'base' | 'icon' | 'label';
4
5
  /** 링크 항목 디폴트 타입 */
5
- export interface SidebarLinkConfig {
6
+ export interface SidebarLinkConfig extends SidebarPermissionGuard {
6
7
  type: 'link';
7
8
  icon?: string;
8
9
  label: string | DirectiveResult;
@@ -1,5 +1,5 @@
1
- import { __decorateMetadata as e } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
2
- import { __decorate as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
4
  import { styles as r } from "./SidebarLink.styles.js";
5
5
  import { html as i } from "lit";
@@ -2,9 +2,10 @@ import { DirectiveResult } from 'lit/directive.js';
2
2
  import { StyledElement, StyleMap } from '../internals/StyledElement.js';
3
3
  import { SidebarLinkConfig } from './SidebarLink.js';
4
4
  import { SidebarGroupConfig } from './SidebarGroup.js';
5
+ import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
5
6
  type ElementParts = 'host' | 'header' | 'title' | 'subtitle' | 'items';
6
7
  /** 섹션 내에는 그룹 또는 링크들만 허용 */
7
- export interface SidebarSectionConfig {
8
+ export interface SidebarSectionConfig extends SidebarPermissionGuard {
8
9
  type: 'section';
9
10
  title: string | DirectiveResult;
10
11
  subTitle?: string | DirectiveResult;
@@ -1,5 +1,5 @@
1
- import { __decorateMetadata as e } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
2
- import { __decorate as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
4
  import { styles as r } from "./SidebarSection.styles.js";
5
5
  import { html as i } from "lit";
@@ -31,3 +31,4 @@ var s = class extends n {
31
31
  };
32
32
  t([o({ type: Boolean }), e("design:type", Object)], s.prototype, "compact", void 0), t([o({ type: String }), e("design:type", Object)], s.prototype, "mainTitle", void 0), t([o({ type: String }), e("design:type", Object)], s.prototype, "subTitle", void 0), s = t([a("u-sidebar-section")], s);
33
33
  //#endregion
34
+ export { s as SidebarSection };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { app } from './App.js';
2
2
  export type * from './types/AppConfigs';
3
3
  export type * from './types/AppOptions';
4
+ export type * from './types/AuthConfig';
5
+ export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
6
+ export { filterSidebarItems } from './layouts/filterSidebarItems.js';
4
7
  export { app };
5
8
  export default app;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { app as e } from "./App.js";
2
+ import { filterSidebarItems as t } from "./layouts/filterSidebarItems.js";
2
3
  //#region src/index.ts
3
- var t = e;
4
+ var n = e;
4
5
  //#endregion
5
- export { e as app, t as default };
6
+ export { e as app, n as default, t as filterSidebarItems };
@@ -1,5 +1,5 @@
1
- import { __decorateMetadata as e } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
2
- import { __decorate as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
3
3
  import { property as n } from "lit/decorators.js";
4
4
  import { UElement as r } from "@iyulab/components/dist/components/UElement.js";
5
5
  //#region src/internals/StyledElement.ts
@@ -20,8 +20,10 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
20
20
  private renderItem;
21
21
  /** 현재 경로와 패턴 매칭 여부 확인 */
22
22
  private isMatchedLink;
23
- /** 브랜드 로고 클릭 핸들러 */
23
+ /** 브랜드 로고 클릭 핸들러: `href` 지정 시 해당 경로로, 아니면 홈으로 이동 */
24
24
  private handleBrandLogoClick;
25
+ /** 로고 렌더링: 아이콘명(문자열, 기존 동작) | 이미지({src,alt,href}) | 커스텀 렌더 함수 */
26
+ private renderLogo;
25
27
  /** 사이드바 토글 핸들러 */
26
28
  private handleToggleButtonClick;
27
29
  /**
@@ -1,30 +1,31 @@
1
1
  import { app as e } from "../App.js";
2
- import { __decorateMetadata as t } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorateMetadata.js";
3
- import { __decorate as n } from "../_virtual/_@oxc-project_runtime@0.133.0/helpers/esm/decorate.js";
4
- import { StyledElement as r } from "../internals/StyledElement.js";
2
+ import { filterSidebarItems as t } from "./filterSidebarItems.js";
3
+ import n from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
4
+ import r from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
5
+ import { StyledElement as i } from "../internals/StyledElement.js";
5
6
  import "../components/SidebarSection.js";
6
7
  import "../components/SidebarLink.js";
7
8
  import "../components/SidebarGroup.js";
8
9
  import "../components/SidebarButton.js";
9
- import { styles as i } from "./SidebarLayout.styles.js";
10
- import { html as a, nothing as o } from "lit";
11
- import { customElement as s, property as c, query as l, state as u } from "lit/decorators.js";
12
- import { unsafeHTML as d } from "lit/directives/unsafe-html.js";
13
- import { repeat as f } from "lit/directives/repeat.js";
10
+ import { styles as a } from "./SidebarLayout.styles.js";
11
+ import { html as o, nothing as s } from "lit";
12
+ import { customElement as c, property as l, query as u, state as d } from "lit/decorators.js";
13
+ import { unsafeHTML as f } from "lit/directives/unsafe-html.js";
14
+ import { repeat as p } from "lit/directives/repeat.js";
14
15
  import "@iyulab/components/dist/components/icon/UIcon.js";
15
16
  import "@iyulab/components/dist/components/button/UButton.js";
16
- import { UProgressBar as p } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
17
+ import { UProgressBar as m } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
17
18
  //#region src/layouts/SidebarLayout.ts
18
- function m(e) {
19
+ function h(e) {
19
20
  let t = e.tagName;
20
21
  if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
21
22
  let n = e.getAttribute("role");
22
23
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
23
24
  }
24
- var h = class extends r {
25
+ var g = class extends i {
25
26
  constructor(...t) {
26
- super(...t), this.state = "default", this.context = null, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleBrandLogoClick = () => {
27
- e.navigate("");
27
+ super(...t), this.state = "default", this.context = null, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleBrandLogoClick = (t) => () => {
28
+ e.navigate(t ?? "");
28
29
  }, this.handleToggleButtonClick = () => {
29
30
  let t = e.screen ?? "large";
30
31
  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);
@@ -44,7 +45,7 @@ var h = class extends r {
44
45
  }, 300);
45
46
  }, this._handleMainKeydown = (e) => {
46
47
  let t = e.composedPath()[0];
47
- if (t instanceof HTMLElement && m(t)) return;
48
+ if (t instanceof HTMLElement && h(t)) return;
48
49
  let n = this.shadowRoot?.querySelector(".main");
49
50
  if (!n) return;
50
51
  let r = n.clientHeight;
@@ -77,7 +78,7 @@ var h = class extends r {
77
78
  };
78
79
  }
79
80
  static {
80
- this.styles = [super.styles, i];
81
+ this.styles = [super.styles, a];
81
82
  }
82
83
  connectedCallback() {
83
84
  super.connectedCallback(), window.addEventListener("route-begin", this.handleRouteBegin), window.addEventListener("route-done", this.handleRouteDone), window.addEventListener("route-progress", this.handleRouteProgress), window.addEventListener("route-error", this.handleRouteError), window.addEventListener("screen-resize", this.handleScreenResize);
@@ -89,13 +90,10 @@ var h = class extends r {
89
90
  super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
90
91
  }
91
92
  render() {
92
- return this.config ? a`
93
+ return this.config ? o`
93
94
  <!-- Mobile Header -->
94
95
  <div class="mobile-header" part="mobile-header" ?hidden="${!this.state.startsWith("mobile")}">
95
- <u-icon class="logo"
96
- .name="${this.config.logo}"
97
- @click=${this.handleBrandLogoClick}
98
- ></u-icon>
96
+ ${this.renderLogo()}
99
97
  <span class="title">
100
98
  ${this.config.title}
101
99
  </span>
@@ -112,10 +110,7 @@ var h = class extends r {
112
110
  <aside class="sidebar" part="sidebar" state="${this.state}">
113
111
  <!-- Sidebar Header -->
114
112
  <div class="sidebar-header" part="sidebar-header">
115
- <u-icon class="logo"
116
- .name="${this.config.logo}"
117
- @click=${this.handleBrandLogoClick}
118
- ></u-icon>
113
+ ${this.renderLogo()}
119
114
  <span class="title" ?hidden=${this.state === "slim"}>
120
115
  ${this.config.title}
121
116
  </span>
@@ -130,12 +125,12 @@ var h = class extends r {
130
125
 
131
126
  <!-- Sidebar Navigation Menu -->
132
127
  <nav class="sidebar-main" part="sidebar-main" scrollable>
133
- ${f(this.config.main ?? [], (e, t) => t, (e) => this.renderItem(e))}
128
+ ${p(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
134
129
  </nav>
135
130
 
136
131
  <!-- Sidebar Footer -->
137
132
  <div class="sidebar-footer" part="sidebar-footer">
138
- ${f(this.config.footer ?? [], (e, t) => t, (e) => this.renderItem(e))}
133
+ ${p(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
139
134
  </div>
140
135
  </aside>
141
136
 
@@ -150,14 +145,14 @@ var h = class extends r {
150
145
  <div class="backdrop" ?hidden="${this.state !== "modal"}"
151
146
  @click="${this.handleBackdropClick}"
152
147
  ></div>
153
- ` : o;
148
+ ` : s;
154
149
  }
155
150
  renderItem(e) {
156
- if (!e) return o;
151
+ if (!e) return s;
157
152
  if (e.type === "html") {
158
153
  let t = e.render(this.state);
159
- return typeof t == "string" ? d(t) : a`${t}`;
160
- } else if (e.type === "button") return a`
154
+ return typeof t == "string" ? f(t) : o`${t}`;
155
+ } else if (e.type === "button") return o`
161
156
  <u-sidebar-button
162
157
  ?compact=${this.state === "slim"}
163
158
  .icon="${e.icon}"
@@ -168,7 +163,7 @@ var h = class extends r {
168
163
  `;
169
164
  else if (e.type === "link") {
170
165
  let t = this.isMatchedLink(e.pattern || e.href);
171
- return a`
166
+ return o`
172
167
  <u-sidebar-link
173
168
  ?compact=${this.state === "slim"}
174
169
  ?selected=${t}
@@ -179,18 +174,18 @@ var h = class extends r {
179
174
  .styles="${e.styles}"
180
175
  ></u-sidebar-link>
181
176
  `;
182
- } else if (e.type === "section") return a`
177
+ } else if (e.type === "section") return o`
183
178
  <u-sidebar-section
184
179
  ?compact=${this.state === "slim"}
185
180
  .mainTitle="${e.title}"
186
181
  .subTitle="${e.subTitle}"
187
182
  .styles="${e.styles}">
188
- ${f(e.items, (e, t) => t, (e) => this.renderItem(e))}
183
+ ${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
189
184
  </u-sidebar-section>
190
185
  `;
191
186
  else if (e.type === "group") {
192
187
  let t = e.items.some((e) => this.isMatchedLink(e.pattern || e.href));
193
- return a`
188
+ return o`
194
189
  <u-sidebar-group
195
190
  ?compact=${this.state === "slim"}
196
191
  ?selected=${t}
@@ -198,15 +193,39 @@ var h = class extends r {
198
193
  .icon="${e.icon}"
199
194
  .label="${e.label}"
200
195
  .styles="${e.styles}">
201
- ${f(e.items, (e, t) => t, (e) => this.renderItem(e))}
196
+ ${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
202
197
  </u-sidebar-group>
203
198
  `;
204
- } else return o;
199
+ } else return s;
200
+ }
201
+ renderLogo() {
202
+ let e = this.config?.logo;
203
+ if (!e || typeof e == "string") return o`
204
+ <u-icon class="logo"
205
+ .name="${e}"
206
+ @click=${this.handleBrandLogoClick()}
207
+ ></u-icon>
208
+ `;
209
+ if (typeof e == "function") {
210
+ let t = e(this.state);
211
+ return o`
212
+ <span class="logo" @click=${this.handleBrandLogoClick()}>
213
+ ${typeof t == "string" ? f(t) : t}
214
+ </span>
215
+ `;
216
+ }
217
+ return o`
218
+ <img class="logo"
219
+ src="${e.src}"
220
+ alt="${e.alt ?? ""}"
221
+ @click=${this.handleBrandLogoClick(e.href)}
222
+ />
223
+ `;
205
224
  }
206
225
  };
207
- n([c({
226
+ r([l({
208
227
  type: String,
209
228
  reflect: !0
210
- }), t("design:type", Object)], h.prototype, "state", void 0), n([c({ type: Object }), t("design:type", Object)], h.prototype, "config", void 0), n([l("u-progress-bar"), t("design:type", p === void 0 ? Object : p)], h.prototype, "progressBarEl", void 0), n([u(), t("design:type", Object)], h.prototype, "context", void 0), h = n([s("u-sidebar-layout")], h);
229
+ }), n("design:type", Object)], g.prototype, "state", void 0), r([l({ type: Object }), n("design:type", Object)], g.prototype, "config", void 0), r([u("u-progress-bar"), n("design:type", m === void 0 ? Object : m)], g.prototype, "progressBarEl", void 0), r([d(), n("design:type", Object)], g.prototype, "context", void 0), g = r([c("u-sidebar-layout")], g);
211
230
  //#endregion
212
- export { h as SidebarLayout };
231
+ export { g as SidebarLayout };
@@ -23,6 +23,11 @@ var t = e`
23
23
  .logo:hover {
24
24
  color: var(--u-txt-color-hover);
25
25
  }
26
+ img.logo {
27
+ height: 24px;
28
+ width: auto;
29
+ object-fit: contain;
30
+ }
26
31
 
27
32
  .title {
28
33
  flex: 1;
@@ -4,28 +4,46 @@ import { SidebarLinkConfig } from '../components/SidebarLink';
4
4
  import { SidebarSectionConfig } from '../components/SidebarSection';
5
5
  import { SidebarGroupConfig } from '../components/SidebarGroup';
6
6
  import { SidebarButtonConfig } from '../components/SidebarButton';
7
+ import { SidebarPermissionGuard } from './SidebarPermission';
8
+ export type { SidebarPermissionGuard } from './SidebarPermission';
7
9
  /** 사이드바 레이아웃 컴포넌트의 요소(part) 타입 */
8
10
  export type SidebarParts = 'host' | 'mobile-header' | 'sidebar' | 'sidebar-header' | 'sidebar-main' | 'sidebar-footer' | 'main' | 'progress';
9
11
  /** 사이드바 상태 타입 */
10
12
  export type SidebarState = 'default' | 'slim' | 'modal' | 'mobile' | 'mobile-open';
11
13
  /** 사이드바 안에 HTML 또는 엘리먼트를 직접 렌더링하는 설정 */
12
- export interface SidebarHtmlConfig {
14
+ export interface SidebarHtmlConfig extends SidebarPermissionGuard {
13
15
  type: 'html';
14
16
  render: (state: SidebarState) => TemplateResult<1> | HTMLElement | string;
15
17
  }
18
+ /** 이미지 로고 설정. `href` 지정 시 클릭 시 기본 홈 이동 대신 해당 경로로 이동 */
19
+ export interface SidebarLogoImage {
20
+ src: string;
21
+ alt?: string;
22
+ href?: string;
23
+ }
24
+ /** 커스텀 로고 렌더 함수. `SidebarHtmlConfig.render`와 동일한 반환 타입 */
25
+ export type SidebarLogoRenderer = (state: SidebarState) => TemplateResult<1> | HTMLElement | string;
26
+ /** 최상단 앱 로고 설정: 아이콘명(문자열, 기존 동작) | 이미지 | 커스텀 렌더 함수 */
27
+ export type SidebarLogoConfig = string | SidebarLogoImage | SidebarLogoRenderer;
16
28
  /** union: section | group | link | button */
17
29
  export type SidebarItem = (SidebarLinkConfig | SidebarSectionConfig | SidebarGroupConfig | SidebarButtonConfig | SidebarHtmlConfig);
18
30
  /** 사이드바 레이아웃 전체 설정 */
19
31
  export interface SidebarLayoutConfig {
20
32
  type: 'sidebar';
21
- /** 최상단 앱 로고 */
22
- logo?: string;
33
+ /** 최상단 앱 로고: 아이콘명(문자열) | 이미지({src,alt,href}) | 커스텀 렌더 함수 */
34
+ logo?: SidebarLogoConfig;
23
35
  /** 앱 제목 */
24
36
  title?: string;
25
37
  /** 상단/메인 메뉴 아이템 항목들 */
26
38
  main?: SidebarItem[];
27
39
  /** 하단(footer)에 고정해서 렌더할 항목들 */
28
40
  footer?: SidebarItem[];
41
+ /**
42
+ * 메뉴 항목 권한 필터 판정. 지정하면 `requirePermission`/`requireAnyPermission` 를 만족하지
43
+ * 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다.
44
+ * 미지정 시 필터링하지 않는다(모든 항목 표시). 보통 `@iyulab/enterprise` 의 `hasPermission` 을 넘긴다.
45
+ */
46
+ hasPermission?: (code: string) => boolean;
29
47
  /** 사이드바 스타일 맵 */
30
48
  styles?: StyleMap<SidebarParts>;
31
49
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 사이드바 메뉴 항목의 권한 가드. 모든 메뉴 config(link/section/group/button/html)가 확장한다.
3
+ *
4
+ * 판정 자체는 `SidebarLayoutConfig.hasPermission` 으로 **주입**한다 — 라이브러리는 권한 코드를
5
+ * 불투명 문자열로만 다루고 의미(도메인)는 앱이 소유한다. `@iyulab/enterprise` 의 `hasPermission`
6
+ * 을 그대로 넘겨 쓰는 것이 일반적이다.
7
+ *
8
+ * 의존성 없는 leaf 모듈 — 각 메뉴 config 파일이 순환 없이 import 한다.
9
+ */
10
+ export interface SidebarPermissionGuard {
11
+ /** 이 권한 코드를 보유해야 항목이 표시된다. */
12
+ requirePermission?: string;
13
+ /** 이 코드 중 하나라도 보유하면 표시된다(빈 배열은 제약 없음). */
14
+ requireAnyPermission?: string[];
15
+ }
@@ -0,0 +1,11 @@
1
+ import { SidebarItem } from './SidebarLayout.types';
2
+ /**
3
+ * 권한에 따라 사이드바 메뉴 트리를 필터링한다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화.
4
+ *
5
+ * - 각 항목의 `requirePermission`/`requireAnyPermission` 를 `hasPermission` 으로 검사해 탈락 항목 제거.
6
+ * - `section`/`group` 은 자식(`items`)을 재귀 필터하고, **자식이 모두 걸러지면 컨테이너째 제거**한다.
7
+ * - `hasPermission` 이 없으면(undefined) 원본을 그대로 반환한다(필터링 없음).
8
+ *
9
+ * 원본 배열/항목을 변형하지 않고 새 배열/얕은 복제본을 반환한다(순수).
10
+ */
11
+ export declare function filterSidebarItems(items: readonly SidebarItem[], hasPermission?: (code: string) => boolean): SidebarItem[];
@@ -0,0 +1,19 @@
1
+ //#region src/layouts/filterSidebarItems.ts
2
+ function e(e, t) {
3
+ return !(e.requirePermission && !t(e.requirePermission) || e.requireAnyPermission && e.requireAnyPermission.length > 0 && !e.requireAnyPermission.some((e) => t(e)));
4
+ }
5
+ function t(n, r) {
6
+ if (!r) return n;
7
+ let i = r, a = [];
8
+ for (let r of n) if (r && e(r, i)) if (r.type === "section" || r.type === "group") {
9
+ let e = t(r.items, i);
10
+ if (e.length === 0) continue;
11
+ a.push({
12
+ ...r,
13
+ items: e
14
+ });
15
+ } else a.push(r);
16
+ return a;
17
+ }
18
+ //#endregion
19
+ export { t as filterSidebarItems };
@@ -1,7 +1,8 @@
1
1
  import { InitOptions, Module, Newable, NewableModule } from 'i18next';
2
- import { RouteConfig, FallbackRouteConfig } from '@iyulab/router';
2
+ import { RouteConfig, FallbackRouteConfig, RouteContext } from '@iyulab/router';
3
3
  import { ThemeInitOptions } from '@iyulab/components/dist/utilities/Theme.js';
4
4
  import { SidebarLayoutConfig } from '../layouts/SidebarLayout.types';
5
+ import { AuthGateConfig } from './AuthConfig';
5
6
  /**
6
7
  * i18next의 초기화 옵션과 플러그인 배열을 포함한 다국어 설정
7
8
  */
@@ -45,6 +46,33 @@ export interface AppConfig {
45
46
  * 라우팅 실패 시 대체 컨텐츠 설정
46
47
  */
47
48
  fallback?: FallbackRouteConfig;
49
+ /**
50
+ * 모든 라우트 전환 전에 호출되는 전역 인증/권한 가드입니다.
51
+ * - `string` 반환: 해당 경로로 redirect
52
+ * - `false` 반환: 네비게이션 취소(403)
53
+ * - `true`/무반환: 통과
54
+ * @example
55
+ * ```typescript
56
+ * app.load({
57
+ * enter: (ctx) => isAuthenticated() || `/login?returnTo=${encodeURIComponent(ctx.pathname)}`,
58
+ * routes: [
59
+ * { path: '/login', render: () => html`<login-page></login-page>` },
60
+ * // ...
61
+ * ],
62
+ * });
63
+ * ```
64
+ */
65
+ enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
66
+ /**
67
+ * 초기 로드 시 현재 URL로 라우팅을 자동으로 수행할지 여부를 설정합니다.
68
+ * @default true
69
+ */
70
+ initialLoad?: boolean;
71
+ /**
72
+ * `a` 태그 클릭 시 클라이언트 라우팅을 수행할지 여부를 설정합니다.
73
+ * @default true
74
+ */
75
+ useIntercept?: boolean;
48
76
  /**
49
77
  * 애플리케이션이 렌더링될 루트 HTML 요소
50
78
  * @default document.body
@@ -65,4 +93,22 @@ export interface AppConfig {
65
93
  * @see 설정에 대한 자세한 내용은 {@link https://www.i18next.com/overview/configuration-options} 참조하십시오.
66
94
  */
67
95
  i18n?: I18nInitOptions;
96
+ /**
97
+ * 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()` 로 세션을 판정하여
98
+ * 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다.
99
+ * @see AuthGateConfig
100
+ * @example
101
+ * ```typescript
102
+ * app.load({
103
+ * layout: { type: 'sidebar', ... },
104
+ * auth: {
105
+ * me: () => authClient.fetchMe(), // null → 미인증
106
+ * renderLogin: ({ root, onSuccess }) => renderLoginPage(root, onSuccess),
107
+ * onAuthenticated: (user) => setPermissions((user as User).Permissions),
108
+ * },
109
+ * routes: [ ... ],
110
+ * });
111
+ * ```
112
+ */
113
+ auth?: AuthGateConfig;
68
114
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 부팅 인증 게이트 설정.
3
+ *
4
+ * `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을 판정한다:
5
+ * `me()` 가 값을 반환하면 인증된 것으로 보고 셸을 로드하고, `null`/`undefined` 면
6
+ * 미인증으로 보고 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
7
+ * 호출하면 앱이 (재)로드되어 셸이 나타난다.
8
+ *
9
+ * 프레임워크는 인증의 **오케스트레이션**(판정 → 분기 → 재로드)만 소유한다. 세션 조회/로그인
10
+ * 자체(HTTP)와 사용자·권한 형태, 세션-중 401 처리는 앱/`@iyulab/enterprise` 가 소유한다.
11
+ */
12
+ export interface AuthGateContext {
13
+ /** 로그인 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
14
+ root: Element;
15
+ /** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는 사용자를 반환해야 한다). */
16
+ onSuccess: () => void;
17
+ }
18
+ export interface AuthGateConfig {
19
+ /**
20
+ * 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다.
21
+ * 동기/비동기 모두 허용한다.
22
+ */
23
+ me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
24
+ /**
25
+ * 미인증 시 `context.root` 에 로그인 UI 를 그린다. 성공하면 `context.onSuccess()` 를 호출해야 한다.
26
+ * 정리 함수를 반환하면 앱 로드/`unload` 시 호출되어 로그인 UI 를 제거한다.
27
+ */
28
+ renderLogin: (context: AuthGateContext) => (() => void) | void;
29
+ /**
30
+ * 인증 성공 후, 앱 셸을 만들기 직전에 호출된다(선택).
31
+ * 사용자별 라우트/메뉴 필터 등 셸 구성 전 처리를 여기서 한다.
32
+ */
33
+ onAuthenticated?: (user: unknown) => void | Promise<void>;
34
+ }
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.3.7",
4
+ "version": "0.6.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -34,19 +34,23 @@
34
34
  }
35
35
  },
36
36
  "scripts": {
37
- "test": "vite",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "start": "vite",
38
40
  "build": "vite build"
39
41
  },
40
42
  "dependencies": {
41
- "@iyulab/components": "^1.0.6",
42
- "@iyulab/router": "^0.9.3",
43
+ "@iyulab/components": "^1.1.1",
44
+ "@iyulab/router": "^0.10.0",
43
45
  "i18next": "^26.2.0",
44
46
  "lit": "^3.3.3"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/node": "^25.8.0",
50
+ "happy-dom": "^20.10.2",
48
51
  "typescript": "^5.9.3",
49
52
  "vite": "^8.0.13",
50
- "vite-plugin-dts": "^5.0.0"
53
+ "vite-plugin-dts": "^5.0.0",
54
+ "vitest": "^4.1.8"
51
55
  }
52
56
  }
@@ -35,7 +35,7 @@ await app.load({
35
35
  basepath: '/',
36
36
  layout: {
37
37
  type: 'sidebar',
38
- logo: '/assets/logo.svg',
38
+ logo: { src: '/assets/logo.svg', alt: 'My App' },
39
39
  title: 'My App',
40
40
  main: [
41
41
  { type: 'link', icon: 'home', label: 'Home', href: '/' },
@@ -157,7 +157,7 @@ Full configuration reference: [references/layout.md](./references/layout.md)
157
157
  ```typescript
158
158
  layout: {
159
159
  type: 'sidebar',
160
- logo: '/logo.svg',
160
+ logo: { src: '/logo.svg', alt: 'App Name' },
161
161
  title: 'App Name',
162
162
  main: [
163
163
  {
@@ -6,8 +6,8 @@
6
6
  interface SidebarLayoutConfig {
7
7
  type: 'sidebar';
8
8
 
9
- /** URL or path to the logo image. */
10
- logo?: string;
9
+ /** Icon name (string) | image ({ src, alt?, href? }) | custom render function. Click navigates to `/` by default, or `href` if given (image variant). */
10
+ logo?: string | { src: string; alt?: string; href?: string } | ((state: SidebarState) => TemplateResult<1> | HTMLElement | string);
11
11
 
12
12
  /** Application title displayed beside the logo. */
13
13
  title?: string;