@franzzemen/brokenstock-shell 0.7.3 → 0.8.1

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, NgZone, viewChild, afterNextRender } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, DestroyRef, NgZone, viewChild, afterNextRender, linkedSignal } from '@angular/core';
3
3
  import * as i2$1 from '@angular/router';
4
4
  import { Router, RouterLink, RouterLinkActive, ActivatedRoute, RouterOutlet } from '@angular/router';
5
5
  import { BEARER_TOKEN_REGEX, HttpHeader, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
@@ -20,6 +20,7 @@ import * as i2 from '@angular/material/tooltip';
20
20
  import { MatTooltipModule } from '@angular/material/tooltip';
21
21
  import * as i1$1 from '@angular/material/progress-spinner';
22
22
  import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
23
+ import { CdkTrapFocus } from '@angular/cdk/a11y';
23
24
  import * as i5 from '@angular/cdk/text-field';
24
25
  import { TextFieldModule } from '@angular/cdk/text-field';
25
26
  import { MatFormField, MatLabel } from '@angular/material/form-field';
@@ -2260,12 +2261,94 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
2260
2261
  args: [{ providedIn: 'root' }]
2261
2262
  }] });
2262
2263
 
2264
+ /*
2265
+ Created by Franz Zemen 2026-10-16
2266
+ License Type: UNLICENSED
2267
+
2268
+ Phone layout of the app frame (broken-stock scanner-phone-display.prd.md D9 / E8).
2269
+ */
2270
+ /**
2271
+ * Below this width the frame is in its phone layout: no icon column, a ☰ menu
2272
+ * button in the top bar, a one-line top bar.
2273
+ *
2274
+ * The same 820px line broken-stock's screens use (scanner-filter-editor.prd.md
2275
+ * D11), written the same way. `819.98px` rather than `819px` so a fractional
2276
+ * width from browser zoom (819.5px) does not fall between the two layouts.
2277
+ *
2278
+ * ⚠ The CSS repeats this query in shell.css and status-bar.css —
2279
+ * a media query cannot read a TypeScript constant. phone-layout.service.spec.ts
2280
+ * fails if any of them drifts from this value.
2281
+ */
2282
+ const SHELL_PHONE_MEDIA_QUERY = '(max-width: 819.98px)';
2283
+ /**
2284
+ * Whether the frame is in its phone layout, and whether the phone menu is open.
2285
+ *
2286
+ * Desktop never sees any of this: `isPhone` is false there, the menu cannot open,
2287
+ * and the rail renders exactly as before.
2288
+ */
2289
+ class PhoneLayoutService {
2290
+ #window;
2291
+ #isPhone;
2292
+ #menuOpen;
2293
+ constructor() {
2294
+ this.#window = inject(DOCUMENT).defaultView;
2295
+ this.#isPhone = signal(false, /* @ts-ignore */
2296
+ ...(ngDevMode ? [{ debugName: "#isPhone" }] : /* istanbul ignore next */ []));
2297
+ this.#menuOpen = signal(false, /* @ts-ignore */
2298
+ ...(ngDevMode ? [{ debugName: "#menuOpen" }] : /* istanbul ignore next */ []));
2299
+ /** True below 820px. Always false where there is no window (server rendering). */
2300
+ this.isPhone = this.#isPhone.asReadonly();
2301
+ /** The ☰ menu's open state. Only ever true while `isPhone` is true. */
2302
+ this.menuOpen = this.#menuOpen.asReadonly();
2303
+ const mql = this.#window?.matchMedia?.(SHELL_PHONE_MEDIA_QUERY);
2304
+ if (!mql)
2305
+ return;
2306
+ this.#isPhone.set(mql.matches);
2307
+ const onChange = (event) => {
2308
+ this.#isPhone.set(event.matches);
2309
+ // Rotating or widening past 820px brings the rail back; a menu left open
2310
+ // would then cover the screen with nothing on it to close it.
2311
+ if (!event.matches)
2312
+ this.#menuOpen.set(false);
2313
+ };
2314
+ mql.addEventListener('change', onChange);
2315
+ inject(DestroyRef).onDestroy(() => mql.removeEventListener('change', onChange));
2316
+ }
2317
+ toggleMenu() {
2318
+ this.#menuOpen.set(this.#isPhone() && !this.#menuOpen());
2319
+ }
2320
+ closeMenu() {
2321
+ this.#menuOpen.set(false);
2322
+ }
2323
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2324
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, providedIn: 'root' }); }
2325
+ }
2326
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, decorators: [{
2327
+ type: Injectable,
2328
+ args: [{ providedIn: 'root' }]
2329
+ }], ctorParameters: () => [] });
2330
+
2331
+ /**
2332
+ * Asks the command box to take the cursor. Called by the shell after most
2333
+ * actions (rail choices, Log Out, ?, ⧉, Escape, sign-in) and by apps.
2334
+ *
2335
+ * ⚠ A NO-OP ON A PHONE (scanner-phone-display E11). The console is not rendered
2336
+ * below 820px, and the one place every focus request passes through is here, so
2337
+ * this is where it is refused: focusing a text box on a phone raises the
2338
+ * on-screen keyboard over the screen the user just opened. Refusing it at the
2339
+ * source also means a request made on a phone is not left counted up, waiting to
2340
+ * be acted on when the console appears after a rotation.
2341
+ */
2263
2342
  class TerminalFocusService {
2264
2343
  constructor() {
2344
+ this.#phoneLayout = inject(PhoneLayoutService);
2265
2345
  this.focusRequested = signal(0, /* @ts-ignore */
2266
2346
  ...(ngDevMode ? [{ debugName: "focusRequested" }] : /* istanbul ignore next */ []));
2267
2347
  }
2348
+ #phoneLayout;
2268
2349
  requestFocus() {
2350
+ if (this.#phoneLayout.isPhone())
2351
+ return;
2269
2352
  this.focusRequested.update(v => v + 1);
2270
2353
  }
2271
2354
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: TerminalFocusService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -2993,7 +3076,12 @@ class StatusBar {
2993
3076
  this.#focusService = inject(TerminalFocusService);
2994
3077
  this.#commandService = inject(CommandService);
2995
3078
  this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
3079
+ this.#phoneLayout = inject(PhoneLayoutService);
2996
3080
  this.isAuthenticated = this.#authService.authenticated;
3081
+ /** The ☰ button: phones only, and only when there is a menu to open (signed in). */
3082
+ this.showMenuButton = computed(() => this.#phoneLayout.isPhone() && this.isAuthenticated(), /* @ts-ignore */
3083
+ ...(ngDevMode ? [{ debugName: "showMenuButton" }] : /* istanbul ignore next */ []));
3084
+ this.menuOpen = this.#phoneLayout.menuOpen;
2997
3085
  /**
2998
3086
  * Whether a second window is offered at all (multiple-windows PRD D14).
2999
3087
  *
@@ -3017,6 +3105,7 @@ class StatusBar {
3017
3105
  #focusService;
3018
3106
  #commandService;
3019
3107
  #sharedSession;
3108
+ #phoneLayout;
3020
3109
  logout() {
3021
3110
  this.#authService.logout();
3022
3111
  this.#commandService.echoCommand('logout');
@@ -3041,16 +3130,19 @@ class StatusBar {
3041
3130
  window.open(window.location.href, '_blank', `noopener,noreferrer,width=${POP_OUT_WIDTH},height=${POP_OUT_HEIGHT}`);
3042
3131
  this.#focusService.requestFocus();
3043
3132
  }
3133
+ toggleMenu() {
3134
+ this.#phoneLayout.toggleMenu();
3135
+ }
3044
3136
  openFaq() {
3045
3137
  this.#commandService.execute('faq');
3046
3138
  this.#focusService.requestFocus();
3047
3139
  }
3048
3140
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3049
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3141
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n @if (showMenuButton()) {\n <!-- Phone only (scanner-phone-display D9): the icon column is folded into this menu. -->\n <button\n type=\"button\"\n class=\"menu-button text-interactive\"\n (click)=\"toggleMenu()\"\n [attr.aria-expanded]=\"menuOpen()\"\n aria-controls=\"shell-phone-menu\"\n aria-label=\"Menu\"\n ><mat-icon>menu</mat-icon></button>\n }\n <div class=\"status-item status-item-own\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}.menu-button{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;margin-left:-4px;background:none;border:none;border-radius:6px;cursor:pointer}.menu-button:hover{background-color:var(--color-surface-light)}@media(max-width:819.98px){.status-bar{gap:12px;padding:0 8px;white-space:nowrap;overflow:hidden}:host ::ng-deep .status-item{min-width:0;flex:0 1 auto}.status-item-own,.faq-button{flex-shrink:0}:host ::ng-deep .status-label:not(.shell-keep-on-phone){display:none}:host ::ng-deep .status-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3050
3142
  }
3051
3143
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, decorators: [{
3052
3144
  type: Component,
3053
- args: [{ selector: 'shell-status-bar', changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"] }]
3145
+ args: [{ selector: 'shell-status-bar', imports: [MatIconModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n @if (showMenuButton()) {\n <!-- Phone only (scanner-phone-display D9): the icon column is folded into this menu. -->\n <button\n type=\"button\"\n class=\"menu-button text-interactive\"\n (click)=\"toggleMenu()\"\n [attr.aria-expanded]=\"menuOpen()\"\n aria-controls=\"shell-phone-menu\"\n aria-label=\"Menu\"\n ><mat-icon>menu</mat-icon></button>\n }\n <div class=\"status-item status-item-own\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}.menu-button{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;margin-left:-4px;background:none;border:none;border-radius:6px;cursor:pointer}.menu-button:hover{background-color:var(--color-surface-light)}@media(max-width:819.98px){.status-bar{gap:12px;padding:0 8px;white-space:nowrap;overflow:hidden}:host ::ng-deep .status-item{min-width:0;flex:0 1 auto}.status-item-own,.faq-button{flex-shrink:0}:host ::ng-deep .status-label:not(.shell-keep-on-phone){display:none}:host ::ng-deep .status-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}\n"] }]
3054
3146
  }] });
3055
3147
 
3056
3148
  class SideMenu {
@@ -3060,7 +3152,27 @@ class SideMenu {
3060
3152
  this.#shortcutService = inject(KeyboardShortcutService);
3061
3153
  this.#terminalVisibility = inject(TerminalVisibilityService);
3062
3154
  this.hasShortcuts = this.#shortcutService.activeShortcuts;
3155
+ this.#phoneLayout = inject(PhoneLayoutService);
3063
3156
  this.terminalVisible = this.#terminalVisibility.visible;
3157
+ /**
3158
+ * Phone-menu rendering (scanner-phone-display D9): the same items as the icon
3159
+ * column, each with its label as visible text — a tooltip needs a hover a
3160
+ * touch screen does not have.
3161
+ *
3162
+ * ⚠ The keyboard-shortcuts button (Alt+?) is left out here. It opens a list of
3163
+ * Alt-key bindings, and a phone has no Alt key. The shortcuts themselves are
3164
+ * untouched — they are document-level listeners and work from a hardware
3165
+ * keyboard whatever the layout.
3166
+ *
3167
+ * The console show/hide button is left out too: the console is not rendered
3168
+ * on a phone at all (Franz, 2026-10-16, scanner-phone-display E11), so the
3169
+ * button would have nothing to show.
3170
+ */
3171
+ this.drawer = input(false, /* @ts-ignore */
3172
+ ...(ngDevMode ? [{ debugName: "drawer" }] : /* istanbul ignore next */ []));
3173
+ /** The console show/hide button — the icon column only (see `drawer`). */
3174
+ this.showConsoleToggle = computed(() => !this.drawer(), /* @ts-ignore */
3175
+ ...(ngDevMode ? [{ debugName: "showConsoleToggle" }] : /* istanbul ignore next */ []));
3064
3176
  this.#allMenuItems = inject(SHELL_MENU_ITEMS, { optional: true }) ?? [];
3065
3177
  this.menuItems = computed(() => this.#allMenuItems.filter(item => !item.visible || item.visible()), /* @ts-ignore */
3066
3178
  ...(ngDevMode ? [{ debugName: "menuItems" }] : /* istanbul ignore next */ []));
@@ -3069,6 +3181,7 @@ class SideMenu {
3069
3181
  #commandService;
3070
3182
  #shortcutService;
3071
3183
  #terminalVisibility;
3184
+ #phoneLayout;
3072
3185
  #allMenuItems;
3073
3186
  /**
3074
3187
  * Readiness, not entitlement — an unentitled item never reaches here, having
@@ -3086,6 +3199,13 @@ class SideMenu {
3086
3199
  if (item.command) {
3087
3200
  this.#commandService.echoCommand(item.command);
3088
3201
  }
3202
+ if (this.drawer()) {
3203
+ // ⚠ No terminal focus from the phone menu. Focusing the command box on a
3204
+ // phone raises the on-screen keyboard over half the screen you just
3205
+ // navigated to.
3206
+ this.#phoneLayout.closeMenu();
3207
+ return;
3208
+ }
3089
3209
  this.#focusService.requestFocus();
3090
3210
  }
3091
3211
  toggleHelp() {
@@ -3095,12 +3215,12 @@ class SideMenu {
3095
3215
  this.#terminalVisibility.toggle();
3096
3216
  }
3097
3217
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3098
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: SideMenu, isStandalone: true, selector: "shell-side-menu", ngImport: i0, template: "<nav class=\"side-menu\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n @if (hasShortcuts()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3218
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: SideMenu, isStandalone: true, selector: "shell-side-menu", inputs: { drawer: { classPropertyName: "drawer", publicName: "drawer", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n @if (showConsoleToggle()) {\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n }\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3099
3219
  }
3100
3220
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, decorators: [{
3101
3221
  type: Component,
3102
- args: [{ selector: 'shell-side-menu', imports: [RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<nav class=\"side-menu\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n @if (hasShortcuts()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}\n"] }]
3103
- }] });
3222
+ args: [{ selector: 'shell-side-menu', imports: [RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n @if (showConsoleToggle()) {\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n }\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"] }]
3223
+ }], propDecorators: { drawer: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawer", required: false }] }] } });
3104
3224
 
3105
3225
  class MainContent {
3106
3226
  constructor() {
@@ -3131,11 +3251,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3131
3251
  }] });
3132
3252
 
3133
3253
  class CommandTerminal {
3254
+ #phoneLayout;
3134
3255
  constructor() {
3135
3256
  this.commandService = inject(CommandService);
3136
3257
  this.registry = inject(CommandRegistryService);
3137
3258
  this.authService = inject(AuthenticationService);
3138
3259
  this.terminalFocusService = inject(TerminalFocusService);
3260
+ this.#phoneLayout = inject(PhoneLayoutService);
3261
+ /**
3262
+ * Whether the command box takes the cursor when the console first appears.
3263
+ *
3264
+ * True for a standalone console and for the console showing at start-up or
3265
+ * after the user turns it back on. The shell passes false when the console
3266
+ * appears because the screen widened past 820px (a tablet or phone rotated to
3267
+ * landscape): a layout change is not the user asking to type, and taking the
3268
+ * cursor there would pull it out of whatever they were doing.
3269
+ */
3270
+ this.focusOnAppear = input(true, /* @ts-ignore */
3271
+ ...(ngDevMode ? [{ debugName: "focusOnAppear" }] : /* istanbul ignore next */ []));
3139
3272
  this.history = computed(() => [...this.commandService.history()].reverse(), /* @ts-ignore */
3140
3273
  ...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
3141
3274
  this.currentInput = this.commandService.currentInput;
@@ -3153,14 +3286,32 @@ class CommandTerminal {
3153
3286
  return suggestions.length > 0 ? suggestions[0] : '';
3154
3287
  }, /* @ts-ignore */
3155
3288
  ...(ngDevMode ? [{ debugName: "ghostText" }] : /* istanbul ignore next */ []));
3156
- afterNextRender(() => this.focusInput());
3289
+ afterNextRender(() => {
3290
+ if (this.focusOnAppear())
3291
+ this.focusInput();
3292
+ });
3293
+ // Both effects below skip their first run. An effect runs once when it is
3294
+ // created, so without the skip each would focus the box on appearance a
3295
+ // second and third time — after `focusOnAppear` had said not to. Appearance
3296
+ // is the afterNextRender above's job alone; these react to CHANGES.
3297
+ let firstProcessingRun = true;
3157
3298
  effect(() => {
3158
- if (!this.isProcessing()) {
3299
+ const busy = this.isProcessing();
3300
+ if (firstProcessingRun) {
3301
+ firstProcessingRun = false;
3302
+ return;
3303
+ }
3304
+ if (!busy) {
3159
3305
  setTimeout(() => this.focusInput());
3160
3306
  }
3161
3307
  });
3308
+ let firstRequestRun = true;
3162
3309
  effect(() => {
3163
3310
  this.terminalFocusService.focusRequested();
3311
+ if (firstRequestRun) {
3312
+ firstRequestRun = false;
3313
+ return;
3314
+ }
3164
3315
  setTimeout(() => this.focusInput());
3165
3316
  });
3166
3317
  }
@@ -3220,16 +3371,23 @@ class CommandTerminal {
3220
3371
  return 'error';
3221
3372
  return '';
3222
3373
  }
3374
+ /**
3375
+ * ⚠ A no-op on a phone (scanner-phone-display E11). The shell does not render
3376
+ * the console there at all; this guard covers an app that mounts the console
3377
+ * itself, so nothing on a phone can raise the on-screen keyboard unasked.
3378
+ */
3223
3379
  focusInput() {
3380
+ if (this.#phoneLayout.isPhone())
3381
+ return;
3224
3382
  this.inputEl()?.nativeElement.focus();
3225
3383
  }
3226
3384
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3227
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CommandTerminal, isStandalone: true, selector: "shell-command-terminal", viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["terminalInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3385
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CommandTerminal, isStandalone: true, selector: "shell-command-terminal", inputs: { focusOnAppear: { classPropertyName: "focusOnAppear", publicName: "focusOnAppear", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["terminalInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3228
3386
  }
3229
3387
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, decorators: [{
3230
3388
  type: Component,
3231
3389
  args: [{ selector: 'shell-command-terminal', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"] }]
3232
- }], ctorParameters: () => [], propDecorators: { inputEl: [{ type: i0.ViewChild, args: ['terminalInput', { isSignal: true }] }] } });
3390
+ }], ctorParameters: () => [], propDecorators: { focusOnAppear: [{ type: i0.Input, args: [{ isSignal: true, alias: "focusOnAppear", required: false }] }], inputEl: [{ type: i0.ViewChild, args: ['terminalInput', { isSignal: true }] }] } });
3233
3391
 
3234
3392
  const MIN_TERMINAL_HEIGHT = 80;
3235
3393
  const MAX_TERMINAL_RATIO = 0.6;
@@ -3243,34 +3401,90 @@ class Shell {
3243
3401
  this.isThinking = this.#thinkingService.isThinking;
3244
3402
  this.isAuthenticated = this.#authService.authenticated;
3245
3403
  this.helpOverlay = this.#commandService.helpOverlay;
3404
+ this.#phoneLayout = inject(PhoneLayoutService);
3246
3405
  this.terminalVisible = this.#terminalVisibility.visible;
3406
+ /** Below 820px the icon column is replaced by the ☰ menu (scanner-phone-display D9). */
3407
+ this.isPhone = this.#phoneLayout.isPhone;
3408
+ this.phoneMenuOpen = this.#phoneLayout.menuOpen;
3409
+ /**
3410
+ * Whether the console (command box, output, and the resize bar above it) is
3411
+ * shown, and whether it takes the cursor when it appears.
3412
+ *
3413
+ * `show`: never on a phone — Franz, 2026-10-16 (scanner-phone-display E11).
3414
+ * The user's show/hide choice is kept untouched, so rotating to a width of
3415
+ * 820px or more brings the console back exactly as it was left. Commands do
3416
+ * not depend on the console: `CommandService.execute` runs from any button
3417
+ * whether or not it is on screen.
3418
+ *
3419
+ * `focusOnAppear`: false only when the console is appearing because the
3420
+ * screen widened past 820px (see CommandTerminal.focusOnAppear); true at
3421
+ * start-up and after the user turns the console on.
3422
+ *
3423
+ * ⚠ ONE SIGNAL, READ UNCONDITIONALLY BY THE TEMPLATE (`[class.terminal-hidden]`),
3424
+ * and that is load-bearing. The focus decision needs the PREVIOUS layout to
3425
+ * tell "widened from a phone" apart from "turned on". A linkedSignal only sees
3426
+ * the state as of its last read, so a value read only inside the console's
3427
+ * `@if` — never read while on a phone — would miss the phone state entirely
3428
+ * and grab the cursor on rotation.
3429
+ */
3430
+ this.consoleState = linkedSignal({ ...(ngDevMode ? { debugName: "consoleState" } : /* istanbul ignore next */ {}), source: () => ({ visible: this.terminalVisible(), phone: this.isPhone() }),
3431
+ computation: (now, previous) => {
3432
+ const show = now.visible && !now.phone;
3433
+ if (!previous)
3434
+ return { show, focusOnAppear: true };
3435
+ if (previous.source.visible !== now.visible)
3436
+ return { show, focusOnAppear: true };
3437
+ if (previous.source.phone && !now.phone)
3438
+ return { show, focusOnAppear: false };
3439
+ return { show, focusOnAppear: previous.value.focusOnAppear };
3440
+ } });
3247
3441
  this.terminalHeight = signal(`${DEFAULT_TERMINAL_HEIGHT}px`, /* @ts-ignore */
3248
3442
  ...(ngDevMode ? [{ debugName: "terminalHeight" }] : /* istanbul ignore next */ []));
3249
3443
  this.isDragging = signal(false, /* @ts-ignore */
3250
3444
  ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
3251
3445
  this.#startY = 0;
3252
3446
  this.#startHeight = DEFAULT_TERMINAL_HEIGHT;
3253
- this.#onMouseMove = (event) => {
3254
- const deltaY = event.clientY - this.#startY;
3255
- const maxHeight = Math.floor(window.innerHeight * MAX_TERMINAL_RATIO);
3256
- const newHeight = Math.min(maxHeight, Math.max(MIN_TERMINAL_HEIGHT, this.#startHeight - deltaY));
3257
- this.terminalHeight.set(`${newHeight}px`);
3258
- };
3447
+ this.#onMouseMove = (event) => this.#resizeTo(event.clientY);
3259
3448
  this.#onMouseUp = () => {
3260
3449
  this.isDragging.set(false);
3261
3450
  document.removeEventListener('mousemove', this.#onMouseMove);
3262
3451
  document.removeEventListener('mouseup', this.#onMouseUp);
3263
3452
  };
3453
+ // Touch drag for the resize bar (scanner-phone-display E12). Only reachable
3454
+ // at 820px and wider — a tablet — since the bar is not rendered on a phone.
3455
+ // Kept separate from the mouse path so the desktop's behaviour is unchanged;
3456
+ // the bar's `touch-action: none` stops the page scrolling under the finger.
3457
+ this.#onTouchMove = (event) => {
3458
+ const touch = event.touches[0];
3459
+ if (touch)
3460
+ this.#resizeTo(touch.clientY);
3461
+ };
3462
+ this.#onTouchEnd = () => {
3463
+ this.isDragging.set(false);
3464
+ document.removeEventListener('touchmove', this.#onTouchMove);
3465
+ document.removeEventListener('touchend', this.#onTouchEnd);
3466
+ document.removeEventListener('touchcancel', this.#onTouchEnd);
3467
+ };
3264
3468
  }
3265
3469
  #thinkingService;
3266
3470
  #commandService;
3267
3471
  #authService;
3268
3472
  #terminalVisibility;
3473
+ #phoneLayout;
3474
+ closePhoneMenu() {
3475
+ this.#phoneLayout.closeMenu();
3476
+ }
3269
3477
  dismissHelp() {
3270
3478
  this.#commandService.dismissHelp();
3271
3479
  }
3272
3480
  #startY;
3273
3481
  #startHeight;
3482
+ #resizeTo(clientY) {
3483
+ const deltaY = clientY - this.#startY;
3484
+ const maxHeight = Math.floor(window.innerHeight * MAX_TERMINAL_RATIO);
3485
+ const newHeight = Math.min(maxHeight, Math.max(MIN_TERMINAL_HEIGHT, this.#startHeight - deltaY));
3486
+ this.terminalHeight.set(`${newHeight}px`);
3487
+ }
3274
3488
  #onMouseMove;
3275
3489
  #onMouseUp;
3276
3490
  onResizeStart(event) {
@@ -3281,12 +3495,29 @@ class Shell {
3281
3495
  document.addEventListener('mousemove', this.#onMouseMove);
3282
3496
  document.addEventListener('mouseup', this.#onMouseUp);
3283
3497
  }
3498
+ // Touch drag for the resize bar (scanner-phone-display E12). Only reachable
3499
+ // at 820px and wider — a tablet — since the bar is not rendered on a phone.
3500
+ // Kept separate from the mouse path so the desktop's behaviour is unchanged;
3501
+ // the bar's `touch-action: none` stops the page scrolling under the finger.
3502
+ #onTouchMove;
3503
+ #onTouchEnd;
3504
+ onResizeTouchStart(event) {
3505
+ const touch = event.touches[0];
3506
+ if (!touch || event.touches.length > 1)
3507
+ return;
3508
+ this.#startY = touch.clientY;
3509
+ this.#startHeight = parseInt(this.terminalHeight(), 10);
3510
+ this.isDragging.set(true);
3511
+ document.addEventListener('touchmove', this.#onTouchMove);
3512
+ document.addEventListener('touchend', this.#onTouchEnd);
3513
+ document.addEventListener('touchcancel', this.#onTouchEnd);
3514
+ }
3284
3515
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3285
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu" }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal" }, { kind: "component", type: FaqModal, selector: "faq-modal" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3516
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!consoleState().show\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (consoleState().show) {\n <!-- Never on a phone (scanner-phone-display E11): see Shell.consoleState. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleState().focusOnAppear\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu", inputs: ["drawer"] }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal", inputs: ["focusOnAppear"] }, { kind: "component", type: FaqModal, selector: "faq-modal" }, { kind: "directive", type: CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3286
3517
  }
3287
3518
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, decorators: [{
3288
3519
  type: Component,
3289
- args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}\n"] }]
3520
+ args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal, CdkTrapFocus], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!consoleState().show\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (consoleState().show) {\n <!-- Never on a phone (scanner-phone-display E11): see Shell.consoleState. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleState().focusOnAppear\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"] }]
3290
3521
  }] });
3291
3522
 
3292
3523
  class Login {
@@ -3511,5 +3742,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3511
3742
  * Generated bundle index. Do not edit.
3512
3743
  */
3513
3744
 
3514
- export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, decideRenewal, guestGuard, matchesRoute, provideShellConfig, tokenize };
3745
+ export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, decideRenewal, guestGuard, matchesRoute, provideShellConfig, tokenize };
3515
3746
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map