@franzzemen/brokenstock-shell 0.8.0 → 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, DestroyRef, 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';
|
|
@@ -2261,12 +2261,94 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2261
2261
|
args: [{ providedIn: 'root' }]
|
|
2262
2262
|
}] });
|
|
2263
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
|
+
*/
|
|
2264
2342
|
class TerminalFocusService {
|
|
2265
2343
|
constructor() {
|
|
2344
|
+
this.#phoneLayout = inject(PhoneLayoutService);
|
|
2266
2345
|
this.focusRequested = signal(0, /* @ts-ignore */
|
|
2267
2346
|
...(ngDevMode ? [{ debugName: "focusRequested" }] : /* istanbul ignore next */ []));
|
|
2268
2347
|
}
|
|
2348
|
+
#phoneLayout;
|
|
2269
2349
|
requestFocus() {
|
|
2350
|
+
if (this.#phoneLayout.isPhone())
|
|
2351
|
+
return;
|
|
2270
2352
|
this.focusRequested.update(v => v + 1);
|
|
2271
2353
|
}
|
|
2272
2354
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: TerminalFocusService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
@@ -2373,73 +2455,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2373
2455
|
args: [{ providedIn: 'root' }]
|
|
2374
2456
|
}] });
|
|
2375
2457
|
|
|
2376
|
-
/*
|
|
2377
|
-
Created by Franz Zemen 2026-10-16
|
|
2378
|
-
License Type: UNLICENSED
|
|
2379
|
-
|
|
2380
|
-
Phone layout of the app frame (broken-stock scanner-phone-display.prd.md D9 / E8).
|
|
2381
|
-
*/
|
|
2382
|
-
/**
|
|
2383
|
-
* Below this width the frame is in its phone layout: no icon column, a ☰ menu
|
|
2384
|
-
* button in the top bar, a one-line top bar.
|
|
2385
|
-
*
|
|
2386
|
-
* The same 820px line broken-stock's screens use (scanner-filter-editor.prd.md
|
|
2387
|
-
* D11), written the same way. `819.98px` rather than `819px` so a fractional
|
|
2388
|
-
* width from browser zoom (819.5px) does not fall between the two layouts.
|
|
2389
|
-
*
|
|
2390
|
-
* ⚠ The CSS repeats this query in shell.css and status-bar.css —
|
|
2391
|
-
* a media query cannot read a TypeScript constant. phone-layout.service.spec.ts
|
|
2392
|
-
* fails if any of them drifts from this value.
|
|
2393
|
-
*/
|
|
2394
|
-
const SHELL_PHONE_MEDIA_QUERY = '(max-width: 819.98px)';
|
|
2395
|
-
/**
|
|
2396
|
-
* Whether the frame is in its phone layout, and whether the phone menu is open.
|
|
2397
|
-
*
|
|
2398
|
-
* Desktop never sees any of this: `isPhone` is false there, the menu cannot open,
|
|
2399
|
-
* and the rail renders exactly as before.
|
|
2400
|
-
*/
|
|
2401
|
-
class PhoneLayoutService {
|
|
2402
|
-
#window;
|
|
2403
|
-
#isPhone;
|
|
2404
|
-
#menuOpen;
|
|
2405
|
-
constructor() {
|
|
2406
|
-
this.#window = inject(DOCUMENT).defaultView;
|
|
2407
|
-
this.#isPhone = signal(false, /* @ts-ignore */
|
|
2408
|
-
...(ngDevMode ? [{ debugName: "#isPhone" }] : /* istanbul ignore next */ []));
|
|
2409
|
-
this.#menuOpen = signal(false, /* @ts-ignore */
|
|
2410
|
-
...(ngDevMode ? [{ debugName: "#menuOpen" }] : /* istanbul ignore next */ []));
|
|
2411
|
-
/** True below 820px. Always false where there is no window (server rendering). */
|
|
2412
|
-
this.isPhone = this.#isPhone.asReadonly();
|
|
2413
|
-
/** The ☰ menu's open state. Only ever true while `isPhone` is true. */
|
|
2414
|
-
this.menuOpen = this.#menuOpen.asReadonly();
|
|
2415
|
-
const mql = this.#window?.matchMedia?.(SHELL_PHONE_MEDIA_QUERY);
|
|
2416
|
-
if (!mql)
|
|
2417
|
-
return;
|
|
2418
|
-
this.#isPhone.set(mql.matches);
|
|
2419
|
-
const onChange = (event) => {
|
|
2420
|
-
this.#isPhone.set(event.matches);
|
|
2421
|
-
// Rotating or widening past 820px brings the rail back; a menu left open
|
|
2422
|
-
// would then cover the screen with nothing on it to close it.
|
|
2423
|
-
if (!event.matches)
|
|
2424
|
-
this.#menuOpen.set(false);
|
|
2425
|
-
};
|
|
2426
|
-
mql.addEventListener('change', onChange);
|
|
2427
|
-
inject(DestroyRef).onDestroy(() => mql.removeEventListener('change', onChange));
|
|
2428
|
-
}
|
|
2429
|
-
toggleMenu() {
|
|
2430
|
-
this.#menuOpen.set(this.#isPhone() && !this.#menuOpen());
|
|
2431
|
-
}
|
|
2432
|
-
closeMenu() {
|
|
2433
|
-
this.#menuOpen.set(false);
|
|
2434
|
-
}
|
|
2435
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2436
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, providedIn: 'root' }); }
|
|
2437
|
-
}
|
|
2438
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: PhoneLayoutService, decorators: [{
|
|
2439
|
-
type: Injectable,
|
|
2440
|
-
args: [{ providedIn: 'root' }]
|
|
2441
|
-
}], ctorParameters: () => [] });
|
|
2442
|
-
|
|
2443
2458
|
/**
|
|
2444
2459
|
* Elements that mean "the user is typing", where an unmodified shortcut must not fire.
|
|
2445
2460
|
*
|
|
@@ -3147,12 +3162,17 @@ class SideMenu {
|
|
|
3147
3162
|
* ⚠ The keyboard-shortcuts button (Alt+?) is left out here. It opens a list of
|
|
3148
3163
|
* Alt-key bindings, and a phone has no Alt key. The shortcuts themselves are
|
|
3149
3164
|
* untouched — they are document-level listeners and work from a hardware
|
|
3150
|
-
* keyboard whatever the layout.
|
|
3151
|
-
*
|
|
3152
|
-
*
|
|
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.
|
|
3153
3170
|
*/
|
|
3154
3171
|
this.drawer = input(false, /* @ts-ignore */
|
|
3155
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 */ []));
|
|
3156
3176
|
this.#allMenuItems = inject(SHELL_MENU_ITEMS, { optional: true }) ?? [];
|
|
3157
3177
|
this.menuItems = computed(() => this.#allMenuItems.filter(item => !item.visible || item.visible()), /* @ts-ignore */
|
|
3158
3178
|
...(ngDevMode ? [{ debugName: "menuItems" }] : /* istanbul ignore next */ []));
|
|
@@ -3193,15 +3213,13 @@ class SideMenu {
|
|
|
3193
3213
|
}
|
|
3194
3214
|
toggleTerminal() {
|
|
3195
3215
|
this.#terminalVisibility.toggle();
|
|
3196
|
-
if (this.drawer())
|
|
3197
|
-
this.#phoneLayout.closeMenu();
|
|
3198
3216
|
}
|
|
3199
3217
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3200
|
-
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 <button\n
|
|
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 }); }
|
|
3201
3219
|
}
|
|
3202
3220
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, decorators: [{
|
|
3203
3221
|
type: Component,
|
|
3204
|
-
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 <button\n
|
|
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"] }]
|
|
3205
3223
|
}], propDecorators: { drawer: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawer", required: false }] }] } });
|
|
3206
3224
|
|
|
3207
3225
|
class MainContent {
|
|
@@ -3233,11 +3251,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3233
3251
|
}] });
|
|
3234
3252
|
|
|
3235
3253
|
class CommandTerminal {
|
|
3254
|
+
#phoneLayout;
|
|
3236
3255
|
constructor() {
|
|
3237
3256
|
this.commandService = inject(CommandService);
|
|
3238
3257
|
this.registry = inject(CommandRegistryService);
|
|
3239
3258
|
this.authService = inject(AuthenticationService);
|
|
3240
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 */ []));
|
|
3241
3272
|
this.history = computed(() => [...this.commandService.history()].reverse(), /* @ts-ignore */
|
|
3242
3273
|
...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
3243
3274
|
this.currentInput = this.commandService.currentInput;
|
|
@@ -3255,14 +3286,32 @@ class CommandTerminal {
|
|
|
3255
3286
|
return suggestions.length > 0 ? suggestions[0] : '';
|
|
3256
3287
|
}, /* @ts-ignore */
|
|
3257
3288
|
...(ngDevMode ? [{ debugName: "ghostText" }] : /* istanbul ignore next */ []));
|
|
3258
|
-
afterNextRender(() =>
|
|
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;
|
|
3259
3298
|
effect(() => {
|
|
3260
|
-
|
|
3299
|
+
const busy = this.isProcessing();
|
|
3300
|
+
if (firstProcessingRun) {
|
|
3301
|
+
firstProcessingRun = false;
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
if (!busy) {
|
|
3261
3305
|
setTimeout(() => this.focusInput());
|
|
3262
3306
|
}
|
|
3263
3307
|
});
|
|
3308
|
+
let firstRequestRun = true;
|
|
3264
3309
|
effect(() => {
|
|
3265
3310
|
this.terminalFocusService.focusRequested();
|
|
3311
|
+
if (firstRequestRun) {
|
|
3312
|
+
firstRequestRun = false;
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
3266
3315
|
setTimeout(() => this.focusInput());
|
|
3267
3316
|
});
|
|
3268
3317
|
}
|
|
@@ -3322,16 +3371,23 @@ class CommandTerminal {
|
|
|
3322
3371
|
return 'error';
|
|
3323
3372
|
return '';
|
|
3324
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
|
+
*/
|
|
3325
3379
|
focusInput() {
|
|
3380
|
+
if (this.#phoneLayout.isPhone())
|
|
3381
|
+
return;
|
|
3326
3382
|
this.inputEl()?.nativeElement.focus();
|
|
3327
3383
|
}
|
|
3328
3384
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3329
|
-
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 }); }
|
|
3330
3386
|
}
|
|
3331
3387
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, decorators: [{
|
|
3332
3388
|
type: Component,
|
|
3333
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"] }]
|
|
3334
|
-
}], 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 }] }] } });
|
|
3335
3391
|
|
|
3336
3392
|
const MIN_TERMINAL_HEIGHT = 80;
|
|
3337
3393
|
const MAX_TERMINAL_RATIO = 0.6;
|
|
@@ -3350,23 +3406,65 @@ class Shell {
|
|
|
3350
3406
|
/** Below 820px the icon column is replaced by the ☰ menu (scanner-phone-display D9). */
|
|
3351
3407
|
this.isPhone = this.#phoneLayout.isPhone;
|
|
3352
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
|
+
} });
|
|
3353
3441
|
this.terminalHeight = signal(`${DEFAULT_TERMINAL_HEIGHT}px`, /* @ts-ignore */
|
|
3354
3442
|
...(ngDevMode ? [{ debugName: "terminalHeight" }] : /* istanbul ignore next */ []));
|
|
3355
3443
|
this.isDragging = signal(false, /* @ts-ignore */
|
|
3356
3444
|
...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
|
|
3357
3445
|
this.#startY = 0;
|
|
3358
3446
|
this.#startHeight = DEFAULT_TERMINAL_HEIGHT;
|
|
3359
|
-
this.#onMouseMove = (event) =>
|
|
3360
|
-
const deltaY = event.clientY - this.#startY;
|
|
3361
|
-
const maxHeight = Math.floor(window.innerHeight * MAX_TERMINAL_RATIO);
|
|
3362
|
-
const newHeight = Math.min(maxHeight, Math.max(MIN_TERMINAL_HEIGHT, this.#startHeight - deltaY));
|
|
3363
|
-
this.terminalHeight.set(`${newHeight}px`);
|
|
3364
|
-
};
|
|
3447
|
+
this.#onMouseMove = (event) => this.#resizeTo(event.clientY);
|
|
3365
3448
|
this.#onMouseUp = () => {
|
|
3366
3449
|
this.isDragging.set(false);
|
|
3367
3450
|
document.removeEventListener('mousemove', this.#onMouseMove);
|
|
3368
3451
|
document.removeEventListener('mouseup', this.#onMouseUp);
|
|
3369
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
|
+
};
|
|
3370
3468
|
}
|
|
3371
3469
|
#thinkingService;
|
|
3372
3470
|
#commandService;
|
|
@@ -3381,6 +3479,12 @@ class Shell {
|
|
|
3381
3479
|
}
|
|
3382
3480
|
#startY;
|
|
3383
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
|
+
}
|
|
3384
3488
|
#onMouseMove;
|
|
3385
3489
|
#onMouseUp;
|
|
3386
3490
|
onResizeStart(event) {
|
|
@@ -3391,12 +3495,29 @@ class Shell {
|
|
|
3391
3495
|
document.addEventListener('mousemove', this.#onMouseMove);
|
|
3392
3496
|
document.addEventListener('mouseup', this.#onMouseUp);
|
|
3393
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
|
+
}
|
|
3394
3515
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3395
|
-
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]=\"!
|
|
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 }); }
|
|
3396
3517
|
}
|
|
3397
3518
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, decorators: [{
|
|
3398
3519
|
type: Component,
|
|
3399
|
-
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]=\"!
|
|
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"] }]
|
|
3400
3521
|
}] });
|
|
3401
3522
|
|
|
3402
3523
|
class Login {
|