@franzzemen/brokenstock-shell 0.0.27 → 0.0.28

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,14 +1,17 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, NgZone, effect, Component, viewChild, afterNextRender } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ChangeDetectionStrategy, Component, NgZone, viewChild, afterNextRender } from '@angular/core';
3
3
  import * as i2$1 from '@angular/router';
4
- import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
4
+ import { Router, RouterLink, RouterLinkActive, RouterOutlet, ActivatedRoute } from '@angular/router';
5
5
  import { HttpHeader, BEARER_TOKEN_REGEX, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
6
6
  import { validateCredentials } from '@franzzemen/identity';
7
7
  import { HttpClient } from '@angular/common/http';
8
- import { tap, catchError, from, switchMap, throwError } from 'rxjs';
8
+ import { tap, catchError, from, switchMap, throwError, firstValueFrom } from 'rxjs';
9
9
  import * as i3 from '@angular/forms';
10
10
  import { NG_VALIDATORS, FormsModule } from '@angular/forms';
11
- import { DOCUMENT, DatePipe } from '@angular/common';
11
+ import { Marked } from 'marked';
12
+ import { gfmHeadingId } from 'marked-gfm-heading-id';
13
+ import fm from 'front-matter';
14
+ import { NgClass, DOCUMENT, DatePipe } from '@angular/common';
12
15
  import { z } from 'zod/v4';
13
16
  import * as i1 from '@angular/material/icon';
14
17
  import { MatIconModule } from '@angular/material/icon';
@@ -241,13 +244,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
241
244
  args: [{ providedIn: 'root' }]
242
245
  }] });
243
246
 
244
- const authGuard = () => {
247
+ const authGuard = (_route, state) => {
245
248
  const authService = inject(AuthenticationService);
246
249
  const router = inject(Router);
247
250
  if (authService.authenticated()) {
248
251
  return true;
249
252
  }
250
- return router.createUrlTree(['/login']);
253
+ return router.createUrlTree(['/login'], {
254
+ queryParams: { returnUrl: state.url },
255
+ });
251
256
  };
252
257
 
253
258
  const guestGuard = () => {
@@ -319,6 +324,918 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
319
324
  }]
320
325
  }], propDecorators: { regex: [{ type: i0.Input, args: [{ isSignal: true, alias: "regex", required: false }] }] } });
321
326
 
327
+ /**
328
+ * Shared types for the FAQ help system.
329
+ *
330
+ * Manifest is generated by the build-time validator (E9) and read at runtime
331
+ * by FaqContentLoader (E1) and FaqService (E2).
332
+ */
333
+
334
+ const FAQ_CONTENT_LOADER = new InjectionToken('FAQ_CONTENT_LOADER');
335
+
336
+ /**
337
+ * Stateless markdown renderer for FAQ content.
338
+ *
339
+ * Two responsibilities:
340
+ * 1. Split a `.md` file into `{ frontmatter, body }` (front-matter parser).
341
+ * 2. Render the body to HTML via marked with a context-aware link hook:
342
+ * - `faq:<key>` — in-modal nav (modal) | `<a href="/help/<file>">` (page)
343
+ * - `app:/path` — close-modal-and-route (modal) | `<a href="/path">` (page)
344
+ * - `http(s)://...` — external, target=_blank rel=noopener (both)
345
+ * - `#anchor` — intra-page scroll (both)
346
+ *
347
+ * In modal context, links are emitted with `data-faq-href` / `data-faq-kind`
348
+ * sentinels so the modal's click handler (E5) can intercept and dispatch.
349
+ * In page context, links are emitted as plain navigable anchors; the prerendered
350
+ * static HTML can be clicked through directly.
351
+ */
352
+ class FaqMarkdownRendererService {
353
+ parse(raw) {
354
+ const parsed = fm(raw);
355
+ return {
356
+ frontmatter: parsed.attributes,
357
+ body: parsed.body,
358
+ };
359
+ }
360
+ render(body, opts) {
361
+ const marked = new Marked({ gfm: true, breaks: false });
362
+ marked.use(gfmHeadingId());
363
+ marked.use({ renderer: this.buildRenderer(opts) });
364
+ return marked.parse(body, { async: false });
365
+ }
366
+ buildRenderer(opts) {
367
+ const { context, imagePrefix } = opts;
368
+ return {
369
+ link: ({ href, title, tokens }) => {
370
+ // marked passes child tokens; we render their textual form ourselves.
371
+ const text = tokens.map((t) => ('text' in t ? t.text : '')).join('');
372
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
373
+ // Intra-page anchor
374
+ if (href.startsWith('#')) {
375
+ return `<a href="${escapeAttr(href)}"${titleAttr} data-faq-kind="anchor">${text}</a>`;
376
+ }
377
+ // faq: protocol — in-modal nav (modal) | static link to /help/<key path> (page)
378
+ if (href.startsWith('faq:')) {
379
+ const key = href.slice('faq:'.length);
380
+ if (context === 'modal') {
381
+ return `<a href="#" data-faq-href="${escapeAttr(href)}" data-faq-kind="faq">${text}</a>`;
382
+ }
383
+ const filePath = key.replace(/\./g, '/');
384
+ return `<a href="/help/${escapeAttr(filePath)}"${titleAttr} data-faq-kind="faq">${text}</a>`;
385
+ }
386
+ // app: protocol — close modal + route (modal) | normal anchor (page)
387
+ if (href.startsWith('app:')) {
388
+ const path = href.slice('app:'.length);
389
+ if (context === 'modal') {
390
+ return `<a href="#" data-faq-href="${escapeAttr(href)}" data-faq-kind="app">${text} <span class="faq-app-arrow" aria-hidden="true">→</span></a>`;
391
+ }
392
+ return `<a href="${escapeAttr(path)}"${titleAttr} data-faq-kind="app">${text} <span class="faq-app-arrow" aria-hidden="true">→</span></a>`;
393
+ }
394
+ // External
395
+ if (/^https?:\/\//i.test(href)) {
396
+ return `<a href="${escapeAttr(href)}"${titleAttr} target="_blank" rel="noopener" data-faq-kind="external">${text}</a>`;
397
+ }
398
+ // Fallback: treat as relative; render plain anchor.
399
+ return `<a href="${escapeAttr(href)}"${titleAttr}>${text}</a>`;
400
+ },
401
+ image: ({ href, title, text }) => {
402
+ const resolved = resolveImageHref(href, imagePrefix);
403
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
404
+ return `<img src="${escapeAttr(resolved)}" alt="${escapeAttr(text)}"${titleAttr} loading="lazy" />`;
405
+ },
406
+ };
407
+ }
408
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqMarkdownRendererService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
409
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqMarkdownRendererService, providedIn: 'root' }); }
410
+ }
411
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqMarkdownRendererService, decorators: [{
412
+ type: Injectable,
413
+ args: [{ providedIn: 'root' }]
414
+ }] });
415
+ function escapeAttr(s) {
416
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
417
+ }
418
+ function resolveImageHref(href, prefix) {
419
+ if (!prefix)
420
+ return href;
421
+ if (/^([a-z]+:)?\/\//i.test(href) || href.startsWith('/'))
422
+ return href;
423
+ const cleaned = prefix.endsWith('/') ? prefix : `${prefix}/`;
424
+ return `${cleaned}${href}`;
425
+ }
426
+
427
+ /**
428
+ * Default FaqContentLoader. Reads the build-generated manifest from
429
+ * `/assets/faq/manifest.json` and lazy-fetches individual `.md` files
430
+ * from `/assets/faq/<entry.file>.md`.
431
+ *
432
+ * Manifest is cached after first load; .md fetches are cached per key.
433
+ */
434
+ class HttpFaqContentLoader {
435
+ constructor() {
436
+ this.http = inject(HttpClient);
437
+ this.manifestPromise = null;
438
+ this.contentCache = new Map();
439
+ }
440
+ loadManifest() {
441
+ if (!this.manifestPromise) {
442
+ this.manifestPromise = firstValueFrom(this.http.get('/assets/faq/manifest.json')).catch((err) => {
443
+ this.manifestPromise = null;
444
+ throw err;
445
+ });
446
+ }
447
+ return this.manifestPromise;
448
+ }
449
+ async loadContent(key) {
450
+ const cached = this.contentCache.get(key);
451
+ if (cached)
452
+ return cached;
453
+ const manifest = await this.loadManifest();
454
+ const entry = manifest.entries.find((e) => e.key === key);
455
+ if (!entry)
456
+ return null;
457
+ const fetchPromise = firstValueFrom(this.http.get(`/assets/faq/${entry.file}.md`, { responseType: 'text' })).catch(() => null);
458
+ this.contentCache.set(key, fetchPromise);
459
+ return fetchPromise;
460
+ }
461
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HttpFaqContentLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
462
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HttpFaqContentLoader, providedIn: 'root' }); }
463
+ }
464
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: HttpFaqContentLoader, decorators: [{
465
+ type: Injectable,
466
+ args: [{ providedIn: 'root' }]
467
+ }] });
468
+
469
+ /**
470
+ * E8 — In-modal navigation history.
471
+ *
472
+ * The browser's `history` API is shared by Angular Router and any other
473
+ * code that calls `pushState`. We tag each FAQ entry's state object with
474
+ * `faq: true` so we can distinguish our entries from router/app entries.
475
+ *
476
+ * On open: `pushState({faq:true, faqPosition:0})` once; stack=[initialKey].
477
+ * On in-modal nav: truncate forward, push key, `pushState({faq:true,
478
+ * faqPosition:position})`.
479
+ * On browser back/forward (`popstate`):
480
+ * - If next state has `faq:true` → set position to its `faqPosition`,
481
+ * swap content for `stack[position]`.
482
+ * - If next state lacks `faq:true` (user backed out of the modal entirely)
483
+ * → close modal without further history manipulation.
484
+ * On explicit close (close button / Esc / backdrop): `history.go(-(position+1))`
485
+ * to unwind our entries; suppress in-flight popstates while unwinding;
486
+ * finalize close once we've seen the expected number of popstates.
487
+ */
488
+ class FaqHistoryService {
489
+ constructor() {
490
+ this._stack = signal([], ...(ngDevMode ? [{ debugName: "_stack" }] : /* istanbul ignore next */ []));
491
+ this._position = signal(0, ...(ngDevMode ? [{ debugName: "_position" }] : /* istanbul ignore next */ []));
492
+ this.stack = this._stack.asReadonly();
493
+ this.position = this._position.asReadonly();
494
+ /** True when popstate is processing the unwind kicked off by closeAndUnwind(). */
495
+ this.unwinding = false;
496
+ this.unwindRemaining = 0;
497
+ this.onUnwindComplete = null;
498
+ this.popstateListener = null;
499
+ this.onInModalNav = null;
500
+ this.onExitModal = null;
501
+ }
502
+ /**
503
+ * Begin a new FAQ history session. Pushes the initial state entry and
504
+ * starts listening for popstate. Callbacks fire for in-modal navs and
505
+ * for popping out of FAQ entirely.
506
+ */
507
+ begin(initialKey, onNav, onExit) {
508
+ this._stack.set([initialKey]);
509
+ this._position.set(0);
510
+ this.onInModalNav = onNav;
511
+ this.onExitModal = onExit;
512
+ this.pushHistoryState(0);
513
+ this.attachListener();
514
+ }
515
+ /** Push a new key onto the stack. Truncates any forward history. */
516
+ navigate(key) {
517
+ const stack = this._stack();
518
+ const pos = this._position();
519
+ const next = [...stack.slice(0, pos + 1), key];
520
+ const nextPos = next.length - 1;
521
+ this._stack.set(next);
522
+ this._position.set(nextPos);
523
+ this.pushHistoryState(nextPos);
524
+ }
525
+ canGoBack() {
526
+ return this._position() > 0;
527
+ }
528
+ canGoForward() {
529
+ return this._position() < this._stack().length - 1;
530
+ }
531
+ /** Programmatic back — triggers a real browser back so popstate handles UI. */
532
+ back() {
533
+ if (!this.canGoBack())
534
+ return;
535
+ history.back();
536
+ }
537
+ forward() {
538
+ if (!this.canGoForward())
539
+ return;
540
+ history.forward();
541
+ }
542
+ /** Currently rendered key. */
543
+ currentKey() {
544
+ return this._stack()[this._position()] ?? null;
545
+ }
546
+ /**
547
+ * Close the modal cleanly by unwinding our FAQ history entries so the
548
+ * underlying app route is left exactly where it was before the modal opened.
549
+ * `onComplete` fires after the unwind settles.
550
+ */
551
+ closeAndUnwind(onComplete) {
552
+ const depth = this._position() + 1;
553
+ if (depth === 0) {
554
+ this.teardown();
555
+ onComplete();
556
+ return;
557
+ }
558
+ this.unwinding = true;
559
+ this.unwindRemaining = depth;
560
+ this.onUnwindComplete = () => {
561
+ this.teardown();
562
+ onComplete();
563
+ };
564
+ history.go(-depth);
565
+ }
566
+ teardown() {
567
+ this._stack.set([]);
568
+ this._position.set(0);
569
+ this.detachListener();
570
+ this.onInModalNav = null;
571
+ this.onExitModal = null;
572
+ this.unwinding = false;
573
+ this.unwindRemaining = 0;
574
+ this.onUnwindComplete = null;
575
+ }
576
+ pushHistoryState(position) {
577
+ history.pushState({ faq: true, faqPosition: position }, '');
578
+ }
579
+ attachListener() {
580
+ if (this.popstateListener)
581
+ return;
582
+ this.popstateListener = (e) => this.handlePopState(e);
583
+ window.addEventListener('popstate', this.popstateListener);
584
+ }
585
+ detachListener() {
586
+ if (!this.popstateListener)
587
+ return;
588
+ window.removeEventListener('popstate', this.popstateListener);
589
+ this.popstateListener = null;
590
+ }
591
+ handlePopState(e) {
592
+ if (this.unwinding) {
593
+ this.unwindRemaining--;
594
+ if (this.unwindRemaining <= 0) {
595
+ const cb = this.onUnwindComplete;
596
+ this.onUnwindComplete = null;
597
+ cb?.();
598
+ }
599
+ return;
600
+ }
601
+ const state = e.state;
602
+ if (state?.faq && typeof state.faqPosition === 'number') {
603
+ // In-modal back/forward
604
+ const target = state.faqPosition;
605
+ this._position.set(target);
606
+ const key = this._stack()[target];
607
+ if (key && this.onInModalNav)
608
+ this.onInModalNav(key);
609
+ return;
610
+ }
611
+ // User backed out beyond our entries — close modal, leave history alone.
612
+ const exit = this.onExitModal;
613
+ this.teardown();
614
+ exit?.();
615
+ }
616
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqHistoryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
617
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqHistoryService, providedIn: 'root' }); }
618
+ }
619
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqHistoryService, decorators: [{
620
+ type: Injectable,
621
+ args: [{ providedIn: 'root' }]
622
+ }] });
623
+
624
+ /**
625
+ * Central FAQ state + resolution service.
626
+ *
627
+ * Responsibilities:
628
+ * - Track the current route's FAQ key (set by components on activation /
629
+ * tab change via `register()`).
630
+ * - Lazy-load and cache the FAQ manifest from the configured FaqContentLoader.
631
+ * - Resolve a dot-notation key against the manifest with parent fallback,
632
+ * audience filtering, and `index` as the terminal fallback.
633
+ */
634
+ class FaqService {
635
+ constructor() {
636
+ this.loader = inject(FAQ_CONTENT_LOADER);
637
+ this.history = inject(FaqHistoryService);
638
+ this.auth = inject(AuthenticationService);
639
+ this._currentFaqKey = signal(null, ...(ngDevMode ? [{ debugName: "_currentFaqKey" }] : /* istanbul ignore next */ []));
640
+ /** Component-set current key. Bare `faq` opens at this key. */
641
+ this.currentFaqKey = this._currentFaqKey.asReadonly();
642
+ this._manifest = signal(null, ...(ngDevMode ? [{ debugName: "_manifest" }] : /* istanbul ignore next */ []));
643
+ this.manifest = this._manifest.asReadonly();
644
+ this.entries = computed(() => this._manifest()?.entries ?? [], ...(ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []));
645
+ // Modal state (E3 + E8).
646
+ this._modalOpen = signal(false, ...(ngDevMode ? [{ debugName: "_modalOpen" }] : /* istanbul ignore next */ []));
647
+ this.modalOpen = this._modalOpen.asReadonly();
648
+ this._activeEntry = signal(null, ...(ngDevMode ? [{ debugName: "_activeEntry" }] : /* istanbul ignore next */ []));
649
+ this.activeEntry = this._activeEntry.asReadonly();
650
+ this._activeContent = signal(null, ...(ngDevMode ? [{ debugName: "_activeContent" }] : /* istanbul ignore next */ []));
651
+ this.activeContent = this._activeContent.asReadonly();
652
+ this._activeAttempted = signal([], ...(ngDevMode ? [{ debugName: "_activeAttempted" }] : /* istanbul ignore next */ []));
653
+ this.activeAttempted = this._activeAttempted.asReadonly();
654
+ this._navigating = signal(false, ...(ngDevMode ? [{ debugName: "_navigating" }] : /* istanbul ignore next */ []));
655
+ this.navigating = this._navigating.asReadonly();
656
+ // Search pre-fill consumed by E4 search UI on modal open / navigateTo.
657
+ this._initialSearchTerm = signal(null, ...(ngDevMode ? [{ debugName: "_initialSearchTerm" }] : /* istanbul ignore next */ []));
658
+ this.initialSearchTerm = this._initialSearchTerm.asReadonly();
659
+ this.canGoBack = computed(() => this.history.position() > 0, ...(ngDevMode ? [{ debugName: "canGoBack" }] : /* istanbul ignore next */ []));
660
+ this.canGoForward = computed(() => this.history.position() < this.history.stack().length - 1, ...(ngDevMode ? [{ debugName: "canGoForward" }] : /* istanbul ignore next */ []));
661
+ this.manifestPromise = null;
662
+ }
663
+ /** Called by the search UI once it has consumed the pre-fill term. */
664
+ consumeInitialSearchTerm() {
665
+ this._initialSearchTerm.set(null);
666
+ }
667
+ /**
668
+ * Triggers manifest load if not already loaded. Idempotent. Subsequent
669
+ * calls return the cached promise.
670
+ */
671
+ ensureManifest() {
672
+ if (this._manifest())
673
+ return Promise.resolve(this._manifest());
674
+ if (!this.manifestPromise) {
675
+ this.manifestPromise = this.loader
676
+ .loadManifest()
677
+ .then((m) => {
678
+ this._manifest.set(m);
679
+ return m;
680
+ })
681
+ .catch((err) => {
682
+ this.manifestPromise = null;
683
+ throw err;
684
+ });
685
+ }
686
+ return this.manifestPromise;
687
+ }
688
+ /**
689
+ * Walks the dot-notation fallback chain (`a.b.c` → `a.b` → `a` → `index`)
690
+ * and returns the first entry whose audience allows the caller and whose
691
+ * content loads. Audience filtering: unauthenticated callers only see
692
+ * `audience: public` entries.
693
+ */
694
+ async resolveKey(key, isAuthenticated) {
695
+ await this.ensureManifest();
696
+ const entries = this.entries();
697
+ const chain = buildFallbackChain(key);
698
+ for (const candidate of chain) {
699
+ const entry = entries.find((e) => e.key === candidate);
700
+ if (!entry)
701
+ continue;
702
+ if (!audienceAllowed(entry.audience, isAuthenticated))
703
+ continue;
704
+ const content = await this.loader.loadContent(entry.key);
705
+ if (content === null)
706
+ continue;
707
+ return { key: entry.key, content, entry, attempted: chain };
708
+ }
709
+ return { key: null, content: null, entry: null, attempted: chain };
710
+ }
711
+ /**
712
+ * Component registration. Sets `currentFaqKey`; on `deregister()` the
713
+ * key is cleared only if it still matches (so a sibling tab activating
714
+ * mid-teardown doesn't get its key clobbered).
715
+ */
716
+ register(key) {
717
+ this._currentFaqKey.set(key);
718
+ return {
719
+ deregister: () => {
720
+ if (this._currentFaqKey() === key)
721
+ this._currentFaqKey.set(null);
722
+ },
723
+ };
724
+ }
725
+ /**
726
+ * E3 — Open the FAQ modal. If `initialKey` is omitted, falls back to
727
+ * `currentFaqKey` and then to `'index'`. Resolves the key, loads content,
728
+ * and starts an E8 history session so subsequent in-modal nav uses
729
+ * `history.pushState`.
730
+ */
731
+ async openModal(initialKey, opts) {
732
+ const searchTerm = opts?.searchTerm?.trim();
733
+ this._initialSearchTerm.set(searchTerm && searchTerm.length > 0 ? searchTerm : null);
734
+ if (this._modalOpen()) {
735
+ // Already open — treat as a navigate-to instead.
736
+ if (initialKey)
737
+ await this.navigateTo(initialKey);
738
+ return;
739
+ }
740
+ const target = initialKey ?? this._currentFaqKey() ?? 'index';
741
+ this._modalOpen.set(true);
742
+ await this.loadIntoActive(target);
743
+ const resolved = this._activeEntry()?.key ?? target;
744
+ this.history.begin(resolved, (key) => {
745
+ // popstate-driven in-modal nav: load without pushing a new entry.
746
+ void this.loadIntoActive(key);
747
+ }, () => {
748
+ // User backed out of FAQ history entirely — close without unwinding.
749
+ this.finalizeClose();
750
+ });
751
+ }
752
+ /**
753
+ * Programmatic in-modal nav. Pushes a new history entry and loads `key`
754
+ * into the active slot. Use this for click handlers on `faq:` links and
755
+ * for the TOC sidebar.
756
+ */
757
+ async navigateTo(key) {
758
+ if (!this._modalOpen()) {
759
+ await this.openModal(key);
760
+ return;
761
+ }
762
+ await this.loadIntoActive(key);
763
+ const resolved = this._activeEntry()?.key ?? key;
764
+ this.history.navigate(resolved);
765
+ }
766
+ back() {
767
+ this.history.back();
768
+ }
769
+ forward() {
770
+ this.history.forward();
771
+ }
772
+ /**
773
+ * Close button / Esc / backdrop close. Unwinds the FAQ history entries
774
+ * so the underlying app route is left untouched.
775
+ */
776
+ closeModal() {
777
+ if (!this._modalOpen())
778
+ return;
779
+ this.history.closeAndUnwind(() => this.finalizeClose());
780
+ }
781
+ finalizeClose() {
782
+ this._modalOpen.set(false);
783
+ this._activeContent.set(null);
784
+ this._activeEntry.set(null);
785
+ this._activeAttempted.set([]);
786
+ }
787
+ async loadIntoActive(key) {
788
+ this._navigating.set(true);
789
+ try {
790
+ const resolution = await this.resolveKey(key, this.auth.authenticated());
791
+ this._activeEntry.set(resolution.entry);
792
+ this._activeContent.set(resolution.content);
793
+ this._activeAttempted.set(resolution.attempted);
794
+ }
795
+ finally {
796
+ this._navigating.set(false);
797
+ }
798
+ }
799
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
800
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqService, providedIn: 'root' }); }
801
+ }
802
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqService, decorators: [{
803
+ type: Injectable,
804
+ args: [{ providedIn: 'root' }]
805
+ }] });
806
+ function buildFallbackChain(key) {
807
+ if (!key)
808
+ return ['index'];
809
+ const parts = key.split('.').filter(Boolean);
810
+ if (parts.length === 0)
811
+ return ['index'];
812
+ const chain = [];
813
+ for (let i = parts.length; i > 0; i--) {
814
+ chain.push(parts.slice(0, i).join('.'));
815
+ }
816
+ chain.push('index');
817
+ return chain;
818
+ }
819
+ function audienceAllowed(audience, isAuthenticated) {
820
+ if (audience === 'public')
821
+ return true;
822
+ return isAuthenticated;
823
+ }
824
+
825
+ /**
826
+ * Gate for the dev-only FAQ authoring panel (E11).
827
+ *
828
+ * Defaults to Angular's `isDevMode()` so the panel auto-enables in `ng serve`
829
+ * and auto-disables in production builds. Consumers can override with an
830
+ * explicit value (e.g. `!environment.production`, or `true` for a staging
831
+ * diagnostic build) via `{provide: FAQ_DEV_MODE, useValue: ...}`.
832
+ *
833
+ * The panel is loaded via dynamic `import()` and lives in its own lazy chunk;
834
+ * builds where this resolves to `false` never request the chunk.
835
+ */
836
+ const FAQ_DEV_MODE = new InjectionToken('FAQ_DEV_MODE', {
837
+ providedIn: 'root',
838
+ factory: () => isDevMode(),
839
+ });
840
+ /**
841
+ * Optional absolute filesystem root of the consuming app's workspace (e.g.
842
+ * `/Users/foo/dev/broken-stock`). When set, the authoring panel emits
843
+ * `webstorm://open?file=<root>/<relative-path>` deeplinks pointing at the
844
+ * exact source file. When unset, the panel falls back to a relative path.
845
+ */
846
+ const FAQ_DEV_WORKSPACE_ROOT = new InjectionToken('FAQ_DEV_WORKSPACE_ROOT', { providedIn: 'root', factory: () => '' });
847
+ const FAQ_DEV_EDITOR = new InjectionToken('FAQ_DEV_EDITOR', { providedIn: 'root', factory: () => 'webstorm' });
848
+
849
+ /**
850
+ * E3 — FAQ modal. Two-pane layout (TOC sidebar + content pane), 80% viewport.
851
+ *
852
+ * E4 — Search uses a lazy-imported `fuse.js` index over title + body,
853
+ * built on first search activation, filtered by audience, cached for the
854
+ * modal session and invalidated on auth state change.
855
+ *
856
+ * E5 — `app:` link clicks close the modal and route to the app path; the
857
+ * returnUrl-aware `authGuard` funnels unauthenticated users through
858
+ * `/login?returnUrl=...`, and `Login` honors returnUrl on success.
859
+ */
860
+ class FaqModal {
861
+ constructor() {
862
+ this.devMode = inject(FAQ_DEV_MODE);
863
+ this.faq = inject(FaqService);
864
+ this.renderer = inject(FaqMarkdownRendererService);
865
+ this.auth = inject(AuthenticationService);
866
+ this.router = inject(Router);
867
+ this.loader = inject(FAQ_CONTENT_LOADER);
868
+ this.open = this.faq.modalOpen;
869
+ this.navigating = this.faq.navigating;
870
+ this.activeEntry = this.faq.activeEntry;
871
+ this.attempted = this.faq.activeAttempted;
872
+ this.canGoBack = this.faq.canGoBack;
873
+ this.canGoForward = this.faq.canGoForward;
874
+ this.sidebarCollapsed = signal(false, ...(ngDevMode ? [{ debugName: "sidebarCollapsed" }] : /* istanbul ignore next */ []));
875
+ this.searchMode = signal(false, ...(ngDevMode ? [{ debugName: "searchMode" }] : /* istanbul ignore next */ []));
876
+ this.searchQuery = signal('', ...(ngDevMode ? [{ debugName: "searchQuery" }] : /* istanbul ignore next */ []));
877
+ this.searchLoading = signal(false, ...(ngDevMode ? [{ debugName: "searchLoading" }] : /* istanbul ignore next */ []));
878
+ this.searchResults = signal([], ...(ngDevMode ? [{ debugName: "searchResults" }] : /* istanbul ignore next */ []));
879
+ this.fuse = null;
880
+ this.fuseBuild = null;
881
+ this.renderedHtml = computed(() => {
882
+ const content = this.faq.activeContent();
883
+ const entry = this.faq.activeEntry();
884
+ if (!content)
885
+ return '';
886
+ return this.renderer.render(content, {
887
+ context: 'modal',
888
+ imagePrefix: entry?.imagePrefix,
889
+ });
890
+ }, ...(ngDevMode ? [{ debugName: "renderedHtml" }] : /* istanbul ignore next */ []));
891
+ this.toc = computed(() => {
892
+ const isAuth = this.auth.authenticated();
893
+ const entries = this.faq.entries().filter((e) => {
894
+ if (e.audience === 'public')
895
+ return true;
896
+ return isAuth;
897
+ });
898
+ const groups = new Map();
899
+ for (const e of entries) {
900
+ const list = groups.get(e.category) ?? [];
901
+ list.push(e);
902
+ groups.set(e.category, list);
903
+ }
904
+ return [...groups.entries()]
905
+ .sort(([a], [b]) => a.localeCompare(b))
906
+ .map(([category, list]) => ({
907
+ category,
908
+ entries: list.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)),
909
+ }));
910
+ }, ...(ngDevMode ? [{ debugName: "toc" }] : /* istanbul ignore next */ []));
911
+ // Scroll content pane to top on key change.
912
+ effect(() => {
913
+ this.activeEntry();
914
+ queueMicrotask(() => {
915
+ this.contentPaneRef?.nativeElement.scrollTo({ top: 0 });
916
+ });
917
+ });
918
+ // Invalidate the search index on auth state change so the audience
919
+ // filter is re-applied on next search activation.
920
+ effect(() => {
921
+ this.auth.authenticated();
922
+ this.fuse = null;
923
+ this.fuseBuild = null;
924
+ if (this.searchMode())
925
+ this.searchResults.set([]);
926
+ });
927
+ // Consume `faq search <term>` pre-fill: when FaqService.initialSearchTerm
928
+ // is non-null while the modal is open, switch to search mode with that
929
+ // query and immediately invoke the index build.
930
+ effect(() => {
931
+ const term = this.faq.initialSearchTerm();
932
+ if (!term || !this.faq.modalOpen())
933
+ return;
934
+ untracked(() => {
935
+ this.searchMode.set(true);
936
+ this.searchQuery.set(term);
937
+ this.faq.consumeInitialSearchTerm();
938
+ void this.ensureSearchIndex().then(() => this.runSearch(term));
939
+ });
940
+ });
941
+ }
942
+ onEscape() {
943
+ if (this.open())
944
+ this.close();
945
+ }
946
+ close() {
947
+ this.faq.closeModal();
948
+ }
949
+ onBackdropClick() {
950
+ this.close();
951
+ }
952
+ stopPropagation(event) {
953
+ event.stopPropagation();
954
+ }
955
+ toggleSidebar() {
956
+ this.sidebarCollapsed.update((v) => !v);
957
+ }
958
+ goBack() {
959
+ this.faq.back();
960
+ }
961
+ goForward() {
962
+ this.faq.forward();
963
+ }
964
+ goIndex() {
965
+ void this.faq.navigateTo('index');
966
+ }
967
+ toggleSearch() {
968
+ const next = !this.searchMode();
969
+ this.searchMode.set(next);
970
+ if (next) {
971
+ void this.ensureSearchIndex().then(() => this.runSearch(this.searchQuery()));
972
+ }
973
+ }
974
+ onSearchInput(event) {
975
+ const value = event.target.value;
976
+ this.searchQuery.set(value);
977
+ void this.ensureSearchIndex().then(() => this.runSearch(value));
978
+ }
979
+ onSearchResultClick(key) {
980
+ void this.faq.navigateTo(key);
981
+ }
982
+ onTocClick(key) {
983
+ void this.faq.navigateTo(key);
984
+ }
985
+ async ensureSearchIndex() {
986
+ if (this.fuse)
987
+ return;
988
+ if (this.fuseBuild)
989
+ return this.fuseBuild;
990
+ this.searchLoading.set(true);
991
+ this.fuseBuild = (async () => {
992
+ const [{ default: Fuse }] = await Promise.all([
993
+ import('fuse.js'),
994
+ this.faq.ensureManifest(),
995
+ ]);
996
+ const isAuth = this.auth.authenticated();
997
+ const entries = this.faq.entries().filter((e) => e.audience === 'public' ? true : isAuth);
998
+ const docs = await Promise.all(entries.map(async (e) => {
999
+ const body = (await this.loader.loadContent(e.key)) ?? '';
1000
+ return { key: e.key, title: e.title, category: e.category, body };
1001
+ }));
1002
+ this.fuse = new Fuse(docs, {
1003
+ keys: [
1004
+ { name: 'title', weight: 0.6 },
1005
+ { name: 'body', weight: 0.4 },
1006
+ ],
1007
+ includeMatches: true,
1008
+ ignoreLocation: true,
1009
+ threshold: 0.4,
1010
+ minMatchCharLength: 2,
1011
+ });
1012
+ })()
1013
+ .catch((err) => {
1014
+ console.error('FAQ search index build failed', err);
1015
+ this.fuseBuild = null;
1016
+ })
1017
+ .finally(() => {
1018
+ this.searchLoading.set(false);
1019
+ });
1020
+ return this.fuseBuild;
1021
+ }
1022
+ runSearch(query) {
1023
+ const q = query.trim();
1024
+ if (!q || !this.fuse) {
1025
+ this.searchResults.set([]);
1026
+ return;
1027
+ }
1028
+ const hits = this.fuse.search(q).slice(0, 30);
1029
+ this.searchResults.set(hits.map((h) => ({
1030
+ key: h.item.key,
1031
+ title: h.item.title,
1032
+ category: h.item.category,
1033
+ snippet: buildSnippet(h.item.body, h.matches),
1034
+ })));
1035
+ }
1036
+ /**
1037
+ * Event-delegated click handler for the rendered content pane.
1038
+ * Intercepts our sentinel anchors emitted by FaqMarkdownRendererService.
1039
+ */
1040
+ onContentClick(event) {
1041
+ const target = event.target;
1042
+ if (!(target instanceof HTMLElement))
1043
+ return;
1044
+ const anchor = target.closest('a[data-faq-kind]');
1045
+ if (!anchor)
1046
+ return;
1047
+ const kind = anchor.getAttribute('data-faq-kind');
1048
+ if (kind === 'external') {
1049
+ return;
1050
+ }
1051
+ if (kind === 'anchor') {
1052
+ event.preventDefault();
1053
+ const href = anchor.getAttribute('href') ?? '';
1054
+ const id = href.startsWith('#') ? href.slice(1) : href;
1055
+ if (!id)
1056
+ return;
1057
+ const pane = this.contentPaneRef?.nativeElement;
1058
+ const el = pane?.querySelector(`#${CSS.escape(id)}`);
1059
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1060
+ return;
1061
+ }
1062
+ const faqHref = anchor.getAttribute('data-faq-href') ?? '';
1063
+ if (kind === 'faq' && faqHref.startsWith('faq:')) {
1064
+ event.preventDefault();
1065
+ const key = faqHref.slice('faq:'.length);
1066
+ void this.faq.navigateTo(key);
1067
+ return;
1068
+ }
1069
+ if (kind === 'app' && faqHref.startsWith('app:')) {
1070
+ event.preventDefault();
1071
+ const path = faqHref.slice('app:'.length);
1072
+ this.close();
1073
+ // Defer route until after modal close finishes unwinding history,
1074
+ // so navigation lands as a fresh entry on top of the pre-modal URL.
1075
+ // The returnUrl-aware authGuard handles unauthenticated callers by
1076
+ // redirecting to /login?returnUrl=<path>; Login honors it on success.
1077
+ queueMicrotask(() => {
1078
+ void this.router.navigateByUrl(path);
1079
+ });
1080
+ return;
1081
+ }
1082
+ }
1083
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqModal, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1084
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: FaqModal, isStandalone: true, selector: "faq-modal", host: { listeners: { "document:keydown.escape": "onEscape()" } }, viewQueries: [{ propertyName: "contentPaneRef", first: true, predicate: ["contentPane"], descendants: true }], ngImport: i0, template: "@if (open()) {\n <div class=\"faq-backdrop\" (click)=\"onBackdropClick()\" role=\"presentation\">\n <div\n class=\"faq-modal\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"activeEntry()?.title ?? 'FAQ'\"\n (click)=\"stopPropagation($event)\"\n >\n <header class=\"faq-toolbar\">\n <div class=\"faq-toolbar-nav\">\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"toggleSidebar()\"\n [attr.aria-label]=\"sidebarCollapsed() ? 'Show contents' : 'Hide contents'\"\n [attr.aria-pressed]=\"!sidebarCollapsed()\"\n >\u2630</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goBack()\"\n [disabled]=\"!canGoBack()\"\n aria-label=\"Back\"\n >\u2039</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goForward()\"\n [disabled]=\"!canGoForward()\"\n aria-label=\"Forward\"\n >\u203A</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goIndex()\"\n aria-label=\"Index\"\n >\u2302</button>\n </div>\n <div class=\"faq-toolbar-title\">\n @if (activeEntry(); as entry) {\n <span class=\"faq-category\">{{ entry.category }}</span>\n <span class=\"faq-sep\">/</span>\n <span class=\"faq-title\">{{ entry.title }}</span>\n } @else {\n <span class=\"faq-title\">FAQ</span>\n }\n </div>\n <div class=\"faq-toolbar-actions\">\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"toggleSearch()\"\n [class.active]=\"searchMode()\"\n aria-label=\"Search\"\n >\u2315</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn faq-close-btn\"\n (click)=\"close()\"\n aria-label=\"Close\"\n >\u2715</button>\n </div>\n </header>\n\n <div class=\"faq-body\" [class.sidebar-collapsed]=\"sidebarCollapsed()\">\n <aside class=\"faq-sidebar\" aria-label=\"FAQ contents\">\n @if (searchMode()) {\n <div class=\"faq-search\">\n <input\n type=\"search\"\n class=\"faq-search-input\"\n placeholder=\"Search FAQ\u2026\"\n aria-label=\"Search FAQ\"\n [value]=\"searchQuery()\"\n (input)=\"onSearchInput($event)\"\n />\n @if (searchLoading()) {\n <p class=\"faq-search-status\">Building index\u2026</p>\n } @else if (!searchQuery().trim()) {\n <p class=\"faq-search-status\">Type to search titles and content.</p>\n } @else if (searchResults().length === 0) {\n <p class=\"faq-search-status\">No matches.</p>\n } @else {\n <ul class=\"faq-search-results\">\n @for (r of searchResults(); track r.key) {\n <li>\n <button\n type=\"button\"\n class=\"faq-search-result\"\n [class.active]=\"activeEntry()?.key === r.key\"\n (click)=\"onSearchResultClick(r.key)\"\n >\n <span class=\"faq-search-result-title\">{{ r.title }}</span>\n <span class=\"faq-search-result-category\">{{ r.category }}</span>\n @if (r.snippet) {\n <span class=\"faq-search-result-snippet\">{{ r.snippet }}</span>\n }\n </button>\n </li>\n }\n </ul>\n }\n </div>\n } @else {\n <nav class=\"faq-toc\">\n @for (group of toc(); track group.category) {\n <div class=\"faq-toc-group\">\n <h3 class=\"faq-toc-category\">{{ group.category }}</h3>\n <ul>\n @for (entry of group.entries; track entry.key) {\n <li>\n <button\n type=\"button\"\n class=\"faq-toc-link\"\n [class.active]=\"activeEntry()?.key === entry.key\"\n (click)=\"onTocClick(entry.key)\"\n >{{ entry.title }}</button>\n </li>\n }\n </ul>\n </div>\n } @empty {\n <p class=\"faq-toc-empty\">No FAQ entries yet.</p>\n }\n </nav>\n }\n </aside>\n\n <main\n #contentPane\n class=\"faq-content\"\n (click)=\"onContentClick($event)\"\n >\n @if (navigating()) {\n <div class=\"faq-loading\">Loading\u2026</div>\n } @else if (renderedHtml(); as html) {\n <article class=\"faq-article\" [innerHTML]=\"html\"></article>\n @if (devMode && activeEntry(); as entry) {\n @defer (when devMode) {\n <faq-authoring-panel\n mode=\"existing\"\n [key]=\"entry.key\"\n [attempted]=\"attempted()\"\n [entry]=\"entry\"\n />\n }\n }\n } @else {\n <div class=\"faq-empty\">\n <h2>No FAQ available</h2>\n <p>No content is registered for this route.</p>\n @if (attempted().length) {\n <p class=\"faq-empty-chain\">\n Attempted keys:\n <code>{{ attempted().join(' \u2192 ') }}</code>\n </p>\n }\n @if (devMode && attempted().length) {\n @defer (when devMode) {\n <faq-authoring-panel\n mode=\"missing\"\n [key]=\"attempted()[0]\"\n [attempted]=\"attempted()\"\n />\n } @loading (minimum 100ms) {\n <p class=\"faq-empty-hint\">Loading authoring panel\u2026</p>\n }\n }\n </div>\n }\n </main>\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}.faq-backdrop{position:fixed;inset:0;z-index:1100;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:5vh 5vw}.faq-modal{width:80vw;height:80vh;max-width:1600px;max-height:1100px;background:var(--shell-surface, #1d1f23);color:var(--shell-fg, #e6e6e6);border-radius:8px;box-shadow:0 12px 32px #00000080;display:grid;grid-template-rows:auto 1fr;overflow:hidden;font-family:var(--shell-font, system-ui, sans-serif)}.faq-toolbar{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--shell-border, rgba(255, 255, 255, .08));background:var(--shell-surface-2, rgba(255, 255, 255, .03))}.faq-toolbar-nav,.faq-toolbar-actions{display:flex;gap:.25rem}.faq-toolbar-title{text-align:center;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.faq-category{opacity:.7;margin-right:.25rem}.faq-sep{opacity:.4;margin:0 .25rem}.faq-title{font-weight:600}.faq-icon-btn{background:transparent;border:1px solid transparent;color:inherit;width:2rem;height:2rem;border-radius:4px;cursor:pointer;font-size:1rem;line-height:1;display:inline-flex;align-items:center;justify-content:center}.faq-icon-btn:hover:not(:disabled){background:#ffffff14}.faq-icon-btn:disabled{opacity:.35;cursor:not-allowed}.faq-icon-btn.active{background:#ffffff1f;border-color:#ffffff2e}.faq-body{display:grid;grid-template-columns:260px 1fr;min-height:0}.faq-body.sidebar-collapsed{grid-template-columns:0 1fr}.faq-body.sidebar-collapsed .faq-sidebar{display:none}.faq-sidebar{border-right:1px solid var(--shell-border, rgba(255, 255, 255, .08));overflow-y:auto;padding:.75rem .5rem;background:var(--shell-surface, #1d1f23)}.faq-toc-group{margin-bottom:1rem}.faq-toc-category{font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.6;margin:0 0 .4rem .4rem}.faq-toc ul{list-style:none;padding:0;margin:0}.faq-toc-link{display:block;width:100%;text-align:left;background:transparent;border:none;color:inherit;padding:.35rem .5rem;border-radius:4px;cursor:pointer;font-size:.88rem}.faq-toc-link:hover{background:#ffffff0f}.faq-toc-link.active{background:#ffffff1f;font-weight:600}.faq-toc-empty{padding:1rem .5rem;opacity:.7;font-size:.85rem}.faq-search{display:flex;flex-direction:column;gap:.5rem;padding:.25rem}.faq-search-input{width:100%;padding:.5rem .6rem;border-radius:4px;border:1px solid rgba(255,255,255,.18);background:#00000040;color:inherit;font-size:.9rem;box-sizing:border-box}.faq-search-input:focus{outline:none;border-color:#fff6}.faq-search-status{padding:.5rem;opacity:.65;font-size:.82rem;margin:0}.faq-search-results{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.15rem}.faq-search-result{display:flex;flex-direction:column;gap:.15rem;width:100%;text-align:left;background:transparent;border:none;color:inherit;padding:.45rem .5rem;border-radius:4px;cursor:pointer}.faq-search-result:hover{background:#ffffff0f}.faq-search-result.active{background:#ffffff1f}.faq-search-result-title{font-size:.9rem;font-weight:600}.faq-search-result-category{font-size:.72rem;opacity:.6;text-transform:uppercase;letter-spacing:.04em}.faq-search-result-snippet{font-size:.78rem;opacity:.75;line-height:1.35}.faq-content{overflow-y:auto;padding:1.25rem 2rem;min-width:0}.faq-loading,.faq-empty{opacity:.75;padding:1rem 0}.faq-empty-chain code{background:#ffffff14;padding:.1rem .4rem;border-radius:3px;font-size:.85rem}.faq-empty-hint{margin-top:1rem;opacity:.55;font-size:.85rem}.faq-article{max-width:70ch;line-height:1.55;font-size:.95rem}.faq-article h1,.faq-article h2,.faq-article h3{font-weight:600;margin:1.4rem 0 .6rem}.faq-article h1{font-size:1.4rem;margin-top:0}.faq-article h2{font-size:1.15rem}.faq-article h3{font-size:1rem}.faq-article p{margin:.6rem 0}.faq-article a{color:var(--shell-link, #6ea8fe);text-decoration:none}.faq-article a:hover{text-decoration:underline}.faq-article a[data-faq-kind=external]:after{content:\" \\2197\";opacity:.65;font-size:.85em}.faq-article .faq-app-arrow{opacity:.7;font-size:.9em}.faq-article code{background:#ffffff14;padding:.1rem .35rem;border-radius:3px;font-size:.88em}.faq-article pre{background:#00000059;padding:.75rem 1rem;border-radius:4px;overflow-x:auto}.faq-article pre code{background:transparent;padding:0}.faq-article img{max-width:100%;border-radius:4px;margin:.6rem 0}.faq-article ul,.faq-article ol{padding-left:1.4rem;margin:.6rem 0}.faq-article blockquote{border-left:3px solid var(--shell-border, rgba(255, 255, 255, .15));padding-left:.9rem;margin:.8rem 0;opacity:.85}@media(max-width:900px){.faq-modal{width:100vw;height:100vh;border-radius:0;max-width:none;max-height:none}.faq-backdrop{padding:0}.faq-body{grid-template-columns:1fr}.faq-sidebar{display:none}.faq-body:not(.sidebar-collapsed) .faq-sidebar{display:block;position:absolute;inset:0;z-index:1}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [Promise.resolve().then(function () { return faqAuthoringPanel; }).then(m => m.FaqAuthoringPanel)], () => [Promise.resolve().then(function () { return faqAuthoringPanel; }).then(m => m.FaqAuthoringPanel)]] }); }
1085
+ }
1086
+ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.13", ngImport: i0, type: FaqModal, resolveDeferredDeps: () => [Promise.resolve().then(function () { return faqAuthoringPanel; }).then(m => m.FaqAuthoringPanel)], resolveMetadata: FaqAuthoringPanel => ({ decorators: [{
1087
+ type: Component,
1088
+ args: [{ selector: 'faq-modal', standalone: true, imports: [FaqAuthoringPanel], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (open()) {\n <div class=\"faq-backdrop\" (click)=\"onBackdropClick()\" role=\"presentation\">\n <div\n class=\"faq-modal\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"activeEntry()?.title ?? 'FAQ'\"\n (click)=\"stopPropagation($event)\"\n >\n <header class=\"faq-toolbar\">\n <div class=\"faq-toolbar-nav\">\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"toggleSidebar()\"\n [attr.aria-label]=\"sidebarCollapsed() ? 'Show contents' : 'Hide contents'\"\n [attr.aria-pressed]=\"!sidebarCollapsed()\"\n >\u2630</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goBack()\"\n [disabled]=\"!canGoBack()\"\n aria-label=\"Back\"\n >\u2039</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goForward()\"\n [disabled]=\"!canGoForward()\"\n aria-label=\"Forward\"\n >\u203A</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"goIndex()\"\n aria-label=\"Index\"\n >\u2302</button>\n </div>\n <div class=\"faq-toolbar-title\">\n @if (activeEntry(); as entry) {\n <span class=\"faq-category\">{{ entry.category }}</span>\n <span class=\"faq-sep\">/</span>\n <span class=\"faq-title\">{{ entry.title }}</span>\n } @else {\n <span class=\"faq-title\">FAQ</span>\n }\n </div>\n <div class=\"faq-toolbar-actions\">\n <button\n type=\"button\"\n class=\"faq-icon-btn\"\n (click)=\"toggleSearch()\"\n [class.active]=\"searchMode()\"\n aria-label=\"Search\"\n >\u2315</button>\n <button\n type=\"button\"\n class=\"faq-icon-btn faq-close-btn\"\n (click)=\"close()\"\n aria-label=\"Close\"\n >\u2715</button>\n </div>\n </header>\n\n <div class=\"faq-body\" [class.sidebar-collapsed]=\"sidebarCollapsed()\">\n <aside class=\"faq-sidebar\" aria-label=\"FAQ contents\">\n @if (searchMode()) {\n <div class=\"faq-search\">\n <input\n type=\"search\"\n class=\"faq-search-input\"\n placeholder=\"Search FAQ\u2026\"\n aria-label=\"Search FAQ\"\n [value]=\"searchQuery()\"\n (input)=\"onSearchInput($event)\"\n />\n @if (searchLoading()) {\n <p class=\"faq-search-status\">Building index\u2026</p>\n } @else if (!searchQuery().trim()) {\n <p class=\"faq-search-status\">Type to search titles and content.</p>\n } @else if (searchResults().length === 0) {\n <p class=\"faq-search-status\">No matches.</p>\n } @else {\n <ul class=\"faq-search-results\">\n @for (r of searchResults(); track r.key) {\n <li>\n <button\n type=\"button\"\n class=\"faq-search-result\"\n [class.active]=\"activeEntry()?.key === r.key\"\n (click)=\"onSearchResultClick(r.key)\"\n >\n <span class=\"faq-search-result-title\">{{ r.title }}</span>\n <span class=\"faq-search-result-category\">{{ r.category }}</span>\n @if (r.snippet) {\n <span class=\"faq-search-result-snippet\">{{ r.snippet }}</span>\n }\n </button>\n </li>\n }\n </ul>\n }\n </div>\n } @else {\n <nav class=\"faq-toc\">\n @for (group of toc(); track group.category) {\n <div class=\"faq-toc-group\">\n <h3 class=\"faq-toc-category\">{{ group.category }}</h3>\n <ul>\n @for (entry of group.entries; track entry.key) {\n <li>\n <button\n type=\"button\"\n class=\"faq-toc-link\"\n [class.active]=\"activeEntry()?.key === entry.key\"\n (click)=\"onTocClick(entry.key)\"\n >{{ entry.title }}</button>\n </li>\n }\n </ul>\n </div>\n } @empty {\n <p class=\"faq-toc-empty\">No FAQ entries yet.</p>\n }\n </nav>\n }\n </aside>\n\n <main\n #contentPane\n class=\"faq-content\"\n (click)=\"onContentClick($event)\"\n >\n @if (navigating()) {\n <div class=\"faq-loading\">Loading\u2026</div>\n } @else if (renderedHtml(); as html) {\n <article class=\"faq-article\" [innerHTML]=\"html\"></article>\n @if (devMode && activeEntry(); as entry) {\n @defer (when devMode) {\n <faq-authoring-panel\n mode=\"existing\"\n [key]=\"entry.key\"\n [attempted]=\"attempted()\"\n [entry]=\"entry\"\n />\n }\n }\n } @else {\n <div class=\"faq-empty\">\n <h2>No FAQ available</h2>\n <p>No content is registered for this route.</p>\n @if (attempted().length) {\n <p class=\"faq-empty-chain\">\n Attempted keys:\n <code>{{ attempted().join(' \u2192 ') }}</code>\n </p>\n }\n @if (devMode && attempted().length) {\n @defer (when devMode) {\n <faq-authoring-panel\n mode=\"missing\"\n [key]=\"attempted()[0]\"\n [attempted]=\"attempted()\"\n />\n } @loading (minimum 100ms) {\n <p class=\"faq-empty-hint\">Loading authoring panel\u2026</p>\n }\n }\n </div>\n }\n </main>\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}.faq-backdrop{position:fixed;inset:0;z-index:1100;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:5vh 5vw}.faq-modal{width:80vw;height:80vh;max-width:1600px;max-height:1100px;background:var(--shell-surface, #1d1f23);color:var(--shell-fg, #e6e6e6);border-radius:8px;box-shadow:0 12px 32px #00000080;display:grid;grid-template-rows:auto 1fr;overflow:hidden;font-family:var(--shell-font, system-ui, sans-serif)}.faq-toolbar{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--shell-border, rgba(255, 255, 255, .08));background:var(--shell-surface-2, rgba(255, 255, 255, .03))}.faq-toolbar-nav,.faq-toolbar-actions{display:flex;gap:.25rem}.faq-toolbar-title{text-align:center;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.faq-category{opacity:.7;margin-right:.25rem}.faq-sep{opacity:.4;margin:0 .25rem}.faq-title{font-weight:600}.faq-icon-btn{background:transparent;border:1px solid transparent;color:inherit;width:2rem;height:2rem;border-radius:4px;cursor:pointer;font-size:1rem;line-height:1;display:inline-flex;align-items:center;justify-content:center}.faq-icon-btn:hover:not(:disabled){background:#ffffff14}.faq-icon-btn:disabled{opacity:.35;cursor:not-allowed}.faq-icon-btn.active{background:#ffffff1f;border-color:#ffffff2e}.faq-body{display:grid;grid-template-columns:260px 1fr;min-height:0}.faq-body.sidebar-collapsed{grid-template-columns:0 1fr}.faq-body.sidebar-collapsed .faq-sidebar{display:none}.faq-sidebar{border-right:1px solid var(--shell-border, rgba(255, 255, 255, .08));overflow-y:auto;padding:.75rem .5rem;background:var(--shell-surface, #1d1f23)}.faq-toc-group{margin-bottom:1rem}.faq-toc-category{font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.6;margin:0 0 .4rem .4rem}.faq-toc ul{list-style:none;padding:0;margin:0}.faq-toc-link{display:block;width:100%;text-align:left;background:transparent;border:none;color:inherit;padding:.35rem .5rem;border-radius:4px;cursor:pointer;font-size:.88rem}.faq-toc-link:hover{background:#ffffff0f}.faq-toc-link.active{background:#ffffff1f;font-weight:600}.faq-toc-empty{padding:1rem .5rem;opacity:.7;font-size:.85rem}.faq-search{display:flex;flex-direction:column;gap:.5rem;padding:.25rem}.faq-search-input{width:100%;padding:.5rem .6rem;border-radius:4px;border:1px solid rgba(255,255,255,.18);background:#00000040;color:inherit;font-size:.9rem;box-sizing:border-box}.faq-search-input:focus{outline:none;border-color:#fff6}.faq-search-status{padding:.5rem;opacity:.65;font-size:.82rem;margin:0}.faq-search-results{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.15rem}.faq-search-result{display:flex;flex-direction:column;gap:.15rem;width:100%;text-align:left;background:transparent;border:none;color:inherit;padding:.45rem .5rem;border-radius:4px;cursor:pointer}.faq-search-result:hover{background:#ffffff0f}.faq-search-result.active{background:#ffffff1f}.faq-search-result-title{font-size:.9rem;font-weight:600}.faq-search-result-category{font-size:.72rem;opacity:.6;text-transform:uppercase;letter-spacing:.04em}.faq-search-result-snippet{font-size:.78rem;opacity:.75;line-height:1.35}.faq-content{overflow-y:auto;padding:1.25rem 2rem;min-width:0}.faq-loading,.faq-empty{opacity:.75;padding:1rem 0}.faq-empty-chain code{background:#ffffff14;padding:.1rem .4rem;border-radius:3px;font-size:.85rem}.faq-empty-hint{margin-top:1rem;opacity:.55;font-size:.85rem}.faq-article{max-width:70ch;line-height:1.55;font-size:.95rem}.faq-article h1,.faq-article h2,.faq-article h3{font-weight:600;margin:1.4rem 0 .6rem}.faq-article h1{font-size:1.4rem;margin-top:0}.faq-article h2{font-size:1.15rem}.faq-article h3{font-size:1rem}.faq-article p{margin:.6rem 0}.faq-article a{color:var(--shell-link, #6ea8fe);text-decoration:none}.faq-article a:hover{text-decoration:underline}.faq-article a[data-faq-kind=external]:after{content:\" \\2197\";opacity:.65;font-size:.85em}.faq-article .faq-app-arrow{opacity:.7;font-size:.9em}.faq-article code{background:#ffffff14;padding:.1rem .35rem;border-radius:3px;font-size:.88em}.faq-article pre{background:#00000059;padding:.75rem 1rem;border-radius:4px;overflow-x:auto}.faq-article pre code{background:transparent;padding:0}.faq-article img{max-width:100%;border-radius:4px;margin:.6rem 0}.faq-article ul,.faq-article ol{padding-left:1.4rem;margin:.6rem 0}.faq-article blockquote{border-left:3px solid var(--shell-border, rgba(255, 255, 255, .15));padding-left:.9rem;margin:.8rem 0;opacity:.85}@media(max-width:900px){.faq-modal{width:100vw;height:100vh;border-radius:0;max-width:none;max-height:none}.faq-backdrop{padding:0}.faq-body{grid-template-columns:1fr}.faq-sidebar{display:none}.faq-body:not(.sidebar-collapsed) .faq-sidebar{display:block;position:absolute;inset:0;z-index:1}}\n"] }]
1089
+ }], ctorParameters: () => [], propDecorators: { contentPaneRef: [{
1090
+ type: ViewChild,
1091
+ args: ['contentPane']
1092
+ }], onEscape: [{
1093
+ type: HostListener,
1094
+ args: ['document:keydown.escape']
1095
+ }] } }) });
1096
+ /**
1097
+ * Builds a short contextual snippet around the first body match. Falls back
1098
+ * to the head of the body when the only matches are in the title.
1099
+ */
1100
+ function buildSnippet(body, matches) {
1101
+ const RADIUS = 60;
1102
+ const MAX = 180;
1103
+ const bodyMatch = matches?.find((m) => m.key === 'body' && m.indices.length);
1104
+ if (!bodyMatch) {
1105
+ return body.replace(/\s+/g, ' ').slice(0, MAX).trim();
1106
+ }
1107
+ const [start, end] = bodyMatch.indices[0];
1108
+ const from = Math.max(0, start - RADIUS);
1109
+ const to = Math.min(body.length, end + RADIUS);
1110
+ let snippet = body.slice(from, to).replace(/\s+/g, ' ').trim();
1111
+ if (from > 0)
1112
+ snippet = '…' + snippet;
1113
+ if (to < body.length)
1114
+ snippet = snippet + '…';
1115
+ return snippet.length > MAX ? snippet.slice(0, MAX) + '…' : snippet;
1116
+ }
1117
+
1118
+ /**
1119
+ * E11 — Dev-only FAQ authoring panel.
1120
+ *
1121
+ * Two modes, both dynamic-imported by `FaqModal` and gated behind
1122
+ * `FAQ_DEV_MODE`:
1123
+ * - `missing`: full scaffold panel rendered when no FAQ resolved.
1124
+ * - `existing`: collapsed source strip rendered below the article when an
1125
+ * entry IS resolved.
1126
+ *
1127
+ * The panel never imports `environment.*` directly — gating is the modal's
1128
+ * responsibility (via the FAQ_DEV_MODE token + dynamic import).
1129
+ */
1130
+ class FaqAuthoringPanel {
1131
+ constructor() {
1132
+ this.faq = inject(FaqService);
1133
+ this.workspaceRoot = inject(FAQ_DEV_WORKSPACE_ROOT);
1134
+ this.editor = inject(FAQ_DEV_EDITOR);
1135
+ /** `'missing'` shows the full scaffold; `'existing'` shows the collapsed strip. */
1136
+ this.mode = input.required(...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
1137
+ /**
1138
+ * The dot-notation key actually being authored. For `missing` mode this is
1139
+ * the deepest attempted key (chain[0]); for `existing` it's the resolved key.
1140
+ */
1141
+ this.key = input.required(...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
1142
+ /** Fallback chain that was attempted. For `missing` every entry is missing. */
1143
+ this.attempted = input([], ...(ngDevMode ? [{ debugName: "attempted" }] : /* istanbul ignore next */ []));
1144
+ /** Resolved entry — only set in `existing` mode. */
1145
+ this.entry = input(null, ...(ngDevMode ? [{ debugName: "entry" }] : /* istanbul ignore next */ []));
1146
+ this.expanded = signal(false, ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
1147
+ this.showAllFiles = signal(false, ...(ngDevMode ? [{ debugName: "showAllFiles" }] : /* istanbul ignore next */ []));
1148
+ this.copyState = signal(null, ...(ngDevMode ? [{ debugName: "copyState" }] : /* istanbul ignore next */ []));
1149
+ this.relPath = computed(() => {
1150
+ const e = this.entry();
1151
+ if (e)
1152
+ return `src/assets/faq/${e.file}.md`;
1153
+ return `src/assets/faq/${keyToFilePath(this.key())}.md`;
1154
+ }, ...(ngDevMode ? [{ debugName: "relPath" }] : /* istanbul ignore next */ []));
1155
+ this.absPath = computed(() => {
1156
+ const rel = this.relPath();
1157
+ return this.workspaceRoot ? joinPosix(this.workspaceRoot, rel) : rel;
1158
+ }, ...(ngDevMode ? [{ debugName: "absPath" }] : /* istanbul ignore next */ []));
1159
+ this.editorHref = computed(() => {
1160
+ const abs = this.absPath();
1161
+ if (this.editor === 'vscode')
1162
+ return `vscode://file/${abs}`;
1163
+ return `webstorm://open?file=${encodeURI(abs)}`;
1164
+ }, ...(ngDevMode ? [{ debugName: "editorHref" }] : /* istanbul ignore next */ []));
1165
+ this.editorLabel = computed(() => this.editor === 'vscode' ? 'Open in VS Code' : 'Open in WebStorm', ...(ngDevMode ? [{ debugName: "editorLabel" }] : /* istanbul ignore next */ []));
1166
+ this.scaffoldCmd = computed(() => `npm run faq:new -- ${this.key()}`, ...(ngDevMode ? [{ debugName: "scaffoldCmd" }] : /* istanbul ignore next */ []));
1167
+ this.frontmatterScaffold = computed(() => {
1168
+ const k = this.key();
1169
+ const title = humanize(k);
1170
+ const category = humanize(k.split('.')[0]);
1171
+ return [
1172
+ '---',
1173
+ `title: "${title}"`,
1174
+ `category: "${category}"`,
1175
+ 'audience: authenticated',
1176
+ 'seo-ready: false',
1177
+ 'description: ""',
1178
+ '---',
1179
+ '',
1180
+ `# ${title}`,
1181
+ '',
1182
+ `TODO: write content for "${title}".`,
1183
+ '',
1184
+ ].join('\n');
1185
+ }, ...(ngDevMode ? [{ debugName: "frontmatterScaffold" }] : /* istanbul ignore next */ []));
1186
+ this.allEntries = computed(() => this.faq.entries(), ...(ngDevMode ? [{ debugName: "allEntries" }] : /* istanbul ignore next */ []));
1187
+ this.missedChain = computed(() => {
1188
+ const present = new Set(this.allEntries().map((e) => e.key));
1189
+ return this.attempted().map((k) => ({ key: k, present: present.has(k) }));
1190
+ }, ...(ngDevMode ? [{ debugName: "missedChain" }] : /* istanbul ignore next */ []));
1191
+ }
1192
+ toggleExpanded() {
1193
+ this.expanded.update((v) => !v);
1194
+ }
1195
+ toggleAllFiles() {
1196
+ this.showAllFiles.update((v) => !v);
1197
+ }
1198
+ async copy(text, label) {
1199
+ try {
1200
+ await navigator.clipboard.writeText(text);
1201
+ this.copyState.set(label);
1202
+ setTimeout(() => {
1203
+ if (this.copyState() === label)
1204
+ this.copyState.set(null);
1205
+ }, 1500);
1206
+ }
1207
+ catch {
1208
+ this.copyState.set(`${label}:err`);
1209
+ }
1210
+ }
1211
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqAuthoringPanel, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1212
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: FaqAuthoringPanel, isStandalone: true, selector: "faq-authoring-panel", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: true, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, attempted: { classPropertyName: "attempted", publicName: "attempted", isSignal: true, isRequired: false, transformFunction: null }, entry: { classPropertyName: "entry", publicName: "entry", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "@if (mode() === 'missing') {\n <section class=\"faq-authoring missing\" aria-label=\"FAQ authoring panel\">\n <header class=\"faq-authoring-header\">\n <span class=\"faq-authoring-tag\">[dev]</span>\n <h3>Start authoring this FAQ</h3>\n </header>\n\n <dl class=\"faq-authoring-meta\">\n <dt>Key</dt>\n <dd><code>{{ key() }}</code></dd>\n\n <dt>Fallback chain</dt>\n <dd>\n <ul class=\"faq-authoring-chain\">\n @for (step of missedChain(); track step.key) {\n <li [ngClass]=\"step.present ? 'present' : 'missed'\">\n <span class=\"mark\">{{ step.present ? '\u2713' : '\u2717' }}</span>\n <code>{{ step.key }}</code>\n </li>\n }\n </ul>\n </dd>\n\n <dt>Target file</dt>\n <dd>\n <code>{{ relPath() }}</code>\n <a\n class=\"faq-authoring-editor\"\n [href]=\"editorHref()\"\n >{{ editorLabel() }} \u2197</a>\n </dd>\n </dl>\n\n <div class=\"faq-authoring-block\">\n <div class=\"faq-authoring-block-head\">\n <span>Scaffold via CLI</span>\n <button type=\"button\" (click)=\"copy(scaffoldCmd(), 'cmd')\">\n {{ copyState() === 'cmd' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n <pre><code>{{ scaffoldCmd() }}</code></pre>\n </div>\n\n <div class=\"faq-authoring-block\">\n <div class=\"faq-authoring-block-head\">\n <span>Frontmatter scaffold</span>\n <button type=\"button\" (click)=\"copy(frontmatterScaffold(), 'fm')\">\n {{ copyState() === 'fm' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n <pre><code>{{ frontmatterScaffold() }}</code></pre>\n </div>\n\n <details class=\"faq-authoring-cheat\">\n <summary>Link syntax cheat sheet</summary>\n <ul>\n <li><code>[label](faq:trade.segments)</code> \u2014 in-modal nav</li>\n <li><code>[label](app:/settings)</code> \u2014 navigates the app (modal closes)</li>\n <li><code>[label](https://example.com)</code> \u2014 external (new tab)</li>\n <li><code>[label](#section-id)</code> \u2014 intra-page anchor</li>\n <li><code>![alt](./images/foo.png)</code> \u2014 image (drop into <code>src/assets/faq/images/</code>)</li>\n </ul>\n </details>\n\n <details class=\"faq-authoring-all\" [open]=\"showAllFiles()\">\n <summary (click)=\"toggleAllFiles(); $event.preventDefault()\">\n Show all FAQ files ({{ allEntries().length }})\n </summary>\n <ul class=\"faq-authoring-file-list\">\n @for (e of allEntries(); track e.key) {\n <li>\n <code>{{ e.key }}</code>\n <span class=\"faq-authoring-file-path\">src/assets/faq/{{ e.file }}.md</span>\n </li>\n } @empty {\n <li class=\"empty\">Manifest is empty.</li>\n }\n </ul>\n </details>\n </section>\n} @else {\n <aside class=\"faq-authoring existing\" aria-label=\"FAQ source info\">\n <div class=\"faq-authoring-strip\">\n <span class=\"faq-authoring-tag\">[dev]</span>\n <span>Key: <code>{{ key() }}</code></span>\n <span class=\"sep\">\u2022</span>\n <span>Source: <code>{{ relPath() }}</code></span>\n <a class=\"faq-authoring-editor\" [href]=\"editorHref()\">{{ editorLabel() }} \u2197</a>\n <button type=\"button\" class=\"faq-authoring-more\" (click)=\"toggleExpanded()\">\n {{ expanded() ? '\u25B2 Less' : '\u25BC More' }}\n </button>\n </div>\n @if (expanded()) {\n <div class=\"faq-authoring-expanded\">\n <div>\n <strong>Fallback chain attempted:</strong>\n <ul class=\"faq-authoring-chain inline\">\n @for (step of missedChain(); track step.key) {\n <li [ngClass]=\"step.present ? 'present' : 'missed'\">\n <span class=\"mark\">{{ step.present ? '\u2713' : '\u2717' }}</span>\n <code>{{ step.key }}</code>\n </li>\n }\n </ul>\n </div>\n <details class=\"faq-authoring-cheat\" open>\n <summary>Link syntax cheat sheet</summary>\n <ul>\n <li><code>[label](faq:trade.segments)</code> \u2014 in-modal nav</li>\n <li><code>[label](app:/settings)</code> \u2014 navigates the app</li>\n <li><code>[label](https://example.com)</code> \u2014 external</li>\n <li><code>[label](#section-id)</code> \u2014 intra-page anchor</li>\n <li><code>![alt](./images/foo.png)</code> \u2014 image</li>\n </ul>\n </details>\n <div class=\"faq-authoring-rescaffold\">\n <span>Re-scaffold (overwrites if you delete first):</span>\n <code>{{ scaffoldCmd() }}</code>\n <button type=\"button\" (click)=\"copy(scaffoldCmd(), 'cmd')\">\n {{ copyState() === 'cmd' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n </div>\n }\n </aside>\n}\n", styles: [":host{display:block;font-family:var(--shell-font, system-ui, sans-serif);font-size:.875rem;color:var(--shell-fg, #e6e6e6)}.faq-authoring{border:1px dashed var(--shell-accent, #6aa0ff);border-radius:6px;background:#6aa0ff0a}.faq-authoring.missing{padding:1rem 1.25rem;display:grid;gap:.85rem}.faq-authoring.existing{margin-top:1.5rem;padding:.5rem .75rem;background:#6aa0ff08;border-style:dotted}.faq-authoring-header{display:flex;align-items:baseline;gap:.5rem;margin:0}.faq-authoring-header h3{margin:0;font-size:1rem}.faq-authoring-tag{display:inline-block;padding:.05rem .4rem;background:var(--shell-accent, #6aa0ff);color:#0b0d11;border-radius:3px;font-size:.7rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.faq-authoring-meta{display:grid;grid-template-columns:max-content 1fr;gap:.35rem .85rem;margin:0}.faq-authoring-meta dt{font-weight:600;opacity:.75}.faq-authoring-meta dd{margin:0}.faq-authoring code{font-family:var(--shell-mono, ui-monospace, monospace);background:#ffffff0f;padding:.05rem .3rem;border-radius:3px;font-size:.85em}.faq-authoring-chain{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:.4rem}.faq-authoring-chain.inline{display:inline-flex;margin-left:.5rem}.faq-authoring-chain li{display:inline-flex;align-items:center;gap:.25rem}.faq-authoring-chain .mark{font-weight:700}.faq-authoring-chain .missed .mark{color:#ff7676}.faq-authoring-chain .present .mark{color:#8ad27a}.faq-authoring-editor{margin-left:.75rem;color:var(--shell-accent, #6aa0ff);text-decoration:none}.faq-authoring-editor:hover{text-decoration:underline}.faq-authoring-block{border:1px solid var(--shell-border, rgba(255, 255, 255, .08));border-radius:4px;overflow:hidden}.faq-authoring-block-head{display:flex;justify-content:space-between;align-items:center;padding:.4rem .6rem;background:#ffffff0a;font-weight:600;font-size:.8rem}.faq-authoring-block-head button,.faq-authoring-more,.faq-authoring-rescaffold button{background:var(--shell-accent, #6aa0ff);color:#0b0d11;border:0;border-radius:3px;padding:.2rem .55rem;font-size:.75rem;cursor:pointer;font-family:inherit}.faq-authoring-block pre{margin:0;padding:.6rem .75rem;overflow-x:auto;background:#00000040;font-size:.8rem}.faq-authoring-cheat,.faq-authoring-all{font-size:.85rem}.faq-authoring-cheat summary,.faq-authoring-all summary{cursor:pointer;opacity:.85}.faq-authoring-cheat ul,.faq-authoring-file-list{margin:.4rem 0 0;padding-left:1.25rem}.faq-authoring-file-list{list-style:none;padding-left:0;max-height:200px;overflow-y:auto}.faq-authoring-file-list li{display:flex;justify-content:space-between;gap:.5rem;padding:.15rem 0;border-bottom:1px solid rgba(255,255,255,.04)}.faq-authoring-file-path{opacity:.6;font-family:var(--shell-mono, ui-monospace, monospace);font-size:.75rem}.faq-authoring-strip{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:.8rem}.faq-authoring-strip .sep{opacity:.4}.faq-authoring-more{margin-left:auto}.faq-authoring-expanded{margin-top:.6rem;padding-top:.5rem;border-top:1px solid rgba(255,255,255,.08);display:grid;gap:.6rem}.faq-authoring-rescaffold{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.8rem}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1213
+ }
1214
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqAuthoringPanel, decorators: [{
1215
+ type: Component,
1216
+ args: [{ selector: 'faq-authoring-panel', standalone: true, imports: [NgClass], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (mode() === 'missing') {\n <section class=\"faq-authoring missing\" aria-label=\"FAQ authoring panel\">\n <header class=\"faq-authoring-header\">\n <span class=\"faq-authoring-tag\">[dev]</span>\n <h3>Start authoring this FAQ</h3>\n </header>\n\n <dl class=\"faq-authoring-meta\">\n <dt>Key</dt>\n <dd><code>{{ key() }}</code></dd>\n\n <dt>Fallback chain</dt>\n <dd>\n <ul class=\"faq-authoring-chain\">\n @for (step of missedChain(); track step.key) {\n <li [ngClass]=\"step.present ? 'present' : 'missed'\">\n <span class=\"mark\">{{ step.present ? '\u2713' : '\u2717' }}</span>\n <code>{{ step.key }}</code>\n </li>\n }\n </ul>\n </dd>\n\n <dt>Target file</dt>\n <dd>\n <code>{{ relPath() }}</code>\n <a\n class=\"faq-authoring-editor\"\n [href]=\"editorHref()\"\n >{{ editorLabel() }} \u2197</a>\n </dd>\n </dl>\n\n <div class=\"faq-authoring-block\">\n <div class=\"faq-authoring-block-head\">\n <span>Scaffold via CLI</span>\n <button type=\"button\" (click)=\"copy(scaffoldCmd(), 'cmd')\">\n {{ copyState() === 'cmd' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n <pre><code>{{ scaffoldCmd() }}</code></pre>\n </div>\n\n <div class=\"faq-authoring-block\">\n <div class=\"faq-authoring-block-head\">\n <span>Frontmatter scaffold</span>\n <button type=\"button\" (click)=\"copy(frontmatterScaffold(), 'fm')\">\n {{ copyState() === 'fm' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n <pre><code>{{ frontmatterScaffold() }}</code></pre>\n </div>\n\n <details class=\"faq-authoring-cheat\">\n <summary>Link syntax cheat sheet</summary>\n <ul>\n <li><code>[label](faq:trade.segments)</code> \u2014 in-modal nav</li>\n <li><code>[label](app:/settings)</code> \u2014 navigates the app (modal closes)</li>\n <li><code>[label](https://example.com)</code> \u2014 external (new tab)</li>\n <li><code>[label](#section-id)</code> \u2014 intra-page anchor</li>\n <li><code>![alt](./images/foo.png)</code> \u2014 image (drop into <code>src/assets/faq/images/</code>)</li>\n </ul>\n </details>\n\n <details class=\"faq-authoring-all\" [open]=\"showAllFiles()\">\n <summary (click)=\"toggleAllFiles(); $event.preventDefault()\">\n Show all FAQ files ({{ allEntries().length }})\n </summary>\n <ul class=\"faq-authoring-file-list\">\n @for (e of allEntries(); track e.key) {\n <li>\n <code>{{ e.key }}</code>\n <span class=\"faq-authoring-file-path\">src/assets/faq/{{ e.file }}.md</span>\n </li>\n } @empty {\n <li class=\"empty\">Manifest is empty.</li>\n }\n </ul>\n </details>\n </section>\n} @else {\n <aside class=\"faq-authoring existing\" aria-label=\"FAQ source info\">\n <div class=\"faq-authoring-strip\">\n <span class=\"faq-authoring-tag\">[dev]</span>\n <span>Key: <code>{{ key() }}</code></span>\n <span class=\"sep\">\u2022</span>\n <span>Source: <code>{{ relPath() }}</code></span>\n <a class=\"faq-authoring-editor\" [href]=\"editorHref()\">{{ editorLabel() }} \u2197</a>\n <button type=\"button\" class=\"faq-authoring-more\" (click)=\"toggleExpanded()\">\n {{ expanded() ? '\u25B2 Less' : '\u25BC More' }}\n </button>\n </div>\n @if (expanded()) {\n <div class=\"faq-authoring-expanded\">\n <div>\n <strong>Fallback chain attempted:</strong>\n <ul class=\"faq-authoring-chain inline\">\n @for (step of missedChain(); track step.key) {\n <li [ngClass]=\"step.present ? 'present' : 'missed'\">\n <span class=\"mark\">{{ step.present ? '\u2713' : '\u2717' }}</span>\n <code>{{ step.key }}</code>\n </li>\n }\n </ul>\n </div>\n <details class=\"faq-authoring-cheat\" open>\n <summary>Link syntax cheat sheet</summary>\n <ul>\n <li><code>[label](faq:trade.segments)</code> \u2014 in-modal nav</li>\n <li><code>[label](app:/settings)</code> \u2014 navigates the app</li>\n <li><code>[label](https://example.com)</code> \u2014 external</li>\n <li><code>[label](#section-id)</code> \u2014 intra-page anchor</li>\n <li><code>![alt](./images/foo.png)</code> \u2014 image</li>\n </ul>\n </details>\n <div class=\"faq-authoring-rescaffold\">\n <span>Re-scaffold (overwrites if you delete first):</span>\n <code>{{ scaffoldCmd() }}</code>\n <button type=\"button\" (click)=\"copy(scaffoldCmd(), 'cmd')\">\n {{ copyState() === 'cmd' ? 'Copied \u2713' : 'Copy' }}\n </button>\n </div>\n </div>\n }\n </aside>\n}\n", styles: [":host{display:block;font-family:var(--shell-font, system-ui, sans-serif);font-size:.875rem;color:var(--shell-fg, #e6e6e6)}.faq-authoring{border:1px dashed var(--shell-accent, #6aa0ff);border-radius:6px;background:#6aa0ff0a}.faq-authoring.missing{padding:1rem 1.25rem;display:grid;gap:.85rem}.faq-authoring.existing{margin-top:1.5rem;padding:.5rem .75rem;background:#6aa0ff08;border-style:dotted}.faq-authoring-header{display:flex;align-items:baseline;gap:.5rem;margin:0}.faq-authoring-header h3{margin:0;font-size:1rem}.faq-authoring-tag{display:inline-block;padding:.05rem .4rem;background:var(--shell-accent, #6aa0ff);color:#0b0d11;border-radius:3px;font-size:.7rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.faq-authoring-meta{display:grid;grid-template-columns:max-content 1fr;gap:.35rem .85rem;margin:0}.faq-authoring-meta dt{font-weight:600;opacity:.75}.faq-authoring-meta dd{margin:0}.faq-authoring code{font-family:var(--shell-mono, ui-monospace, monospace);background:#ffffff0f;padding:.05rem .3rem;border-radius:3px;font-size:.85em}.faq-authoring-chain{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:.4rem}.faq-authoring-chain.inline{display:inline-flex;margin-left:.5rem}.faq-authoring-chain li{display:inline-flex;align-items:center;gap:.25rem}.faq-authoring-chain .mark{font-weight:700}.faq-authoring-chain .missed .mark{color:#ff7676}.faq-authoring-chain .present .mark{color:#8ad27a}.faq-authoring-editor{margin-left:.75rem;color:var(--shell-accent, #6aa0ff);text-decoration:none}.faq-authoring-editor:hover{text-decoration:underline}.faq-authoring-block{border:1px solid var(--shell-border, rgba(255, 255, 255, .08));border-radius:4px;overflow:hidden}.faq-authoring-block-head{display:flex;justify-content:space-between;align-items:center;padding:.4rem .6rem;background:#ffffff0a;font-weight:600;font-size:.8rem}.faq-authoring-block-head button,.faq-authoring-more,.faq-authoring-rescaffold button{background:var(--shell-accent, #6aa0ff);color:#0b0d11;border:0;border-radius:3px;padding:.2rem .55rem;font-size:.75rem;cursor:pointer;font-family:inherit}.faq-authoring-block pre{margin:0;padding:.6rem .75rem;overflow-x:auto;background:#00000040;font-size:.8rem}.faq-authoring-cheat,.faq-authoring-all{font-size:.85rem}.faq-authoring-cheat summary,.faq-authoring-all summary{cursor:pointer;opacity:.85}.faq-authoring-cheat ul,.faq-authoring-file-list{margin:.4rem 0 0;padding-left:1.25rem}.faq-authoring-file-list{list-style:none;padding-left:0;max-height:200px;overflow-y:auto}.faq-authoring-file-list li{display:flex;justify-content:space-between;gap:.5rem;padding:.15rem 0;border-bottom:1px solid rgba(255,255,255,.04)}.faq-authoring-file-path{opacity:.6;font-family:var(--shell-mono, ui-monospace, monospace);font-size:.75rem}.faq-authoring-strip{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:.8rem}.faq-authoring-strip .sep{opacity:.4}.faq-authoring-more{margin-left:auto}.faq-authoring-expanded{margin-top:.6rem;padding-top:.5rem;border-top:1px solid rgba(255,255,255,.08);display:grid;gap:.6rem}.faq-authoring-rescaffold{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.8rem}\n"] }]
1217
+ }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: true }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], attempted: [{ type: i0.Input, args: [{ isSignal: true, alias: "attempted", required: false }] }], entry: [{ type: i0.Input, args: [{ isSignal: true, alias: "entry", required: false }] }] } });
1218
+ function keyToFilePath(key) {
1219
+ return key.split('.').join('/');
1220
+ }
1221
+ function humanize(s) {
1222
+ return s
1223
+ .split(/[.\-_/]/)
1224
+ .filter(Boolean)
1225
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
1226
+ .join(' ');
1227
+ }
1228
+ function joinPosix(a, b) {
1229
+ const left = a.endsWith('/') ? a.slice(0, -1) : a;
1230
+ const right = b.startsWith('/') ? b.slice(1) : b;
1231
+ return `${left}/${right}`;
1232
+ }
1233
+
1234
+ var faqAuthoringPanel = /*#__PURE__*/Object.freeze({
1235
+ __proto__: null,
1236
+ FaqAuthoringPanel: FaqAuthoringPanel
1237
+ });
1238
+
322
1239
  class ThemeService {
323
1240
  constructor() {
324
1241
  this.mode = signal('dark', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
@@ -431,9 +1348,67 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
431
1348
  args: [{ providedIn: 'root' }]
432
1349
  }], ctorParameters: () => [] });
433
1350
 
1351
+ const FAQ_USAGE = 'faq\n' +
1352
+ 'faq <key>\n' +
1353
+ 'faq search <term>\n' +
1354
+ 'faq index\n' +
1355
+ 'faq help\n\n' +
1356
+ ' Opens the in-app FAQ help system. Does not require authentication.\n\n' +
1357
+ ' Subcommands:\n' +
1358
+ ' (none) Open the FAQ at the current route\'s resolved key.\n' +
1359
+ ' <key> Open at a specific dot-notation key (e.g. trade.segments).\n' +
1360
+ ' search <term> Open with the search field pre-filled.\n' +
1361
+ ' index Open at the index table-of-contents.\n' +
1362
+ ' help Show this help.';
1363
+ class FaqCommand {
1364
+ constructor() {
1365
+ this.faqService = inject(FaqService);
1366
+ this.name = 'faq';
1367
+ this.description = 'Open the in-app FAQ help system';
1368
+ this.usage = FAQ_USAGE;
1369
+ this.requiresAuth = false;
1370
+ this.argsSchema = z.array(z.string()).max(50);
1371
+ }
1372
+ execute(args) {
1373
+ const ts = () => new Date();
1374
+ if (args.length === 0) {
1375
+ void this.faqService.openModal();
1376
+ return { success: true, message: '', timestamp: ts() };
1377
+ }
1378
+ const sub = args[0].toLowerCase();
1379
+ if (sub === 'index') {
1380
+ void this.faqService.openModal('index');
1381
+ return { success: true, message: '', timestamp: ts() };
1382
+ }
1383
+ if (sub === 'search') {
1384
+ const term = args.slice(1).join(' ').trim();
1385
+ if (!term) {
1386
+ return {
1387
+ success: false,
1388
+ message: 'Usage: faq search <term>',
1389
+ timestamp: ts(),
1390
+ };
1391
+ }
1392
+ void this.faqService.openModal(undefined, { searchTerm: term });
1393
+ return { success: true, message: '', timestamp: ts() };
1394
+ }
1395
+ void this.faqService.openModal(args[0]);
1396
+ return { success: true, message: '', timestamp: ts() };
1397
+ }
1398
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqCommand, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1399
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqCommand, providedIn: 'root' }); }
1400
+ }
1401
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: FaqCommand, decorators: [{
1402
+ type: Injectable,
1403
+ args: [{ providedIn: 'root' }]
1404
+ }] });
1405
+
434
1406
  class CommandRegistryService {
435
1407
  constructor() {
436
1408
  this.commands = new Map();
1409
+ // Built-in shell commands registered ahead of app-provided commands so
1410
+ // an app cannot accidentally shadow them.
1411
+ this.register(inject(FaqCommand));
437
1412
  const appCommands = inject(SHELL_COMMANDS, { optional: true }) ?? [];
438
1413
  for (const cmd of appCommands) {
439
1414
  this.register(cmd);
@@ -456,7 +1431,8 @@ class CommandRegistryService {
456
1431
  if (!prefix)
457
1432
  return [];
458
1433
  if (!isAuthenticated) {
459
- return 'login'.startsWith(prefix) && 'login' !== prefix ? ['login'] : [];
1434
+ const open = ['login', ...[...this.commands.values()].filter(c => c.requiresAuth === false).map(c => c.name)];
1435
+ return open.filter(name => name.startsWith(prefix) && name !== prefix).sort();
460
1436
  }
461
1437
  const builtIns = ['clear', 'help'];
462
1438
  const registered = [...this.commands.values()].map(c => c.name);
@@ -596,16 +1572,22 @@ class CommandService {
596
1572
  }
597
1573
  const hasHelp = args.some(a => a.toLowerCase() === 'help');
598
1574
  const isAuthenticated = this.authService.authenticated();
599
- if (!isAuthenticated && command !== 'login') {
600
- return { success: false, message: 'Not logged in. Use "login" to authenticate.', timestamp: new Date() };
601
- }
1575
+ const notLoggedIn = () => ({
1576
+ success: false,
1577
+ message: 'Not logged in. Use "login" to authenticate.',
1578
+ timestamp: new Date(),
1579
+ });
602
1580
  if (command === 'clear') {
1581
+ if (!isAuthenticated)
1582
+ return notLoggedIn();
603
1583
  if (hasHelp)
604
1584
  return { success: true, message: CLEAR_USAGE, timestamp: new Date(), isHelp: true };
605
1585
  this.history.set([]);
606
1586
  return { success: true, message: '', timestamp: new Date() };
607
1587
  }
608
1588
  if (command === 'help') {
1589
+ if (!isAuthenticated)
1590
+ return notLoggedIn();
609
1591
  const target = args[0]?.toLowerCase();
610
1592
  if (target)
611
1593
  return this.#commandHelp(target);
@@ -632,9 +1614,14 @@ class CommandService {
632
1614
  command = swapped;
633
1615
  }
634
1616
  else {
1617
+ if (!isAuthenticated && command !== 'login')
1618
+ return notLoggedIn();
635
1619
  return { success: false, message: `Unknown command: ${command}`, timestamp: new Date() };
636
1620
  }
637
1621
  }
1622
+ if (!isAuthenticated && command !== 'login' && def.requiresAuth !== false) {
1623
+ return notLoggedIn();
1624
+ }
638
1625
  if (hasHelp) {
639
1626
  return { success: true, message: def.usage, timestamp: new Date(), isHelp: true };
640
1627
  }
@@ -682,12 +1669,16 @@ class StatusBar {
682
1669
  this.#commandService.echoCommand('logout');
683
1670
  this.#focusService.requestFocus();
684
1671
  }
1672
+ openFaq() {
1673
+ this.#commandService.execute('faq');
1674
+ this.#focusService.requestFocus();
1675
+ }
685
1676
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: StatusBar, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
686
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", 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</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}\n"] }); }
1677
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", 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 <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"] }); }
687
1678
  }
688
1679
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: StatusBar, decorators: [{
689
1680
  type: Component,
690
- args: [{ selector: 'shell-status-bar', 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</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}\n"] }]
1681
+ args: [{ selector: 'shell-status-bar', 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 <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"] }]
691
1682
  }] });
692
1683
 
693
1684
  class SideMenu {
@@ -892,17 +1883,18 @@ class Shell {
892
1883
  document.addEventListener('mouseup', this.#onMouseUp);
893
1884
  }
894
1885
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: Shell, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
895
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\">\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 <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\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-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" }] }); }
1886
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\">\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 <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\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-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" }] }); }
896
1887
  }
897
1888
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: Shell, decorators: [{
898
1889
  type: Component,
899
- args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal], template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\">\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 <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\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-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"] }]
1890
+ args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal], template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\">\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 <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" />\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-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"] }]
900
1891
  }] });
901
1892
 
902
1893
  class Login {
903
1894
  #postLoginRoute;
904
1895
  #commandService;
905
1896
  #terminalFocusService;
1897
+ #activatedRoute;
906
1898
  constructor(authenticationService, router) {
907
1899
  this.authenticationService = authenticationService;
908
1900
  this.router = router;
@@ -917,6 +1909,24 @@ class Login {
917
1909
  this.#postLoginRoute = inject(SHELL_POST_LOGIN_ROUTE, { optional: true }) ?? '/';
918
1910
  this.#commandService = inject(CommandService);
919
1911
  this.#terminalFocusService = inject(TerminalFocusService);
1912
+ this.#activatedRoute = inject(ActivatedRoute);
1913
+ }
1914
+ /**
1915
+ * Returns the post-login destination. Honors a `returnUrl` query param if
1916
+ * present and safe (same-origin, leading `/`, not protocol-relative);
1917
+ * otherwise falls back to `SHELL_POST_LOGIN_ROUTE`.
1918
+ */
1919
+ #resolvePostLoginUrl() {
1920
+ const candidate = this.#activatedRoute.snapshot.queryParamMap.get('returnUrl');
1921
+ if (!candidate)
1922
+ return this.#postLoginRoute;
1923
+ if (!candidate.startsWith('/'))
1924
+ return this.#postLoginRoute;
1925
+ if (candidate.startsWith('//'))
1926
+ return this.#postLoginRoute;
1927
+ if (/^https?:\/\//i.test(candidate))
1928
+ return this.#postLoginRoute;
1929
+ return candidate;
920
1930
  }
921
1931
  onAutofill(event, inputEl) {
922
1932
  if (event.isAutofilled) {
@@ -961,7 +1971,7 @@ class Login {
961
1971
  .login(this.model, force)
962
1972
  .then(() => {
963
1973
  this.#commandService.echoCommand(`login ${this.model.username}`);
964
- this.router.navigate([this.#postLoginRoute]).then(() => {
1974
+ this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
965
1975
  setTimeout(() => this.#terminalFocusService.requestFocus());
966
1976
  }).catch(console.error);
967
1977
  })
@@ -1038,5 +2048,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
1038
2048
  * Generated bundle index. Do not edit.
1039
2049
  */
1040
2050
 
1041
- export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, HELP_USAGE, KeyboardShortcutService, Login, MainContent, RegexValidatorDirective, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, Shell, SideMenu, StatusBar, TerminalFocusService, ThemeService, ThinkingService, authGuard, authInterceptor, guestGuard, provideShellConfig, tokenize };
2051
+ export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, RegexValidatorDirective, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, Shell, SideMenu, StatusBar, TerminalFocusService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, guestGuard, provideShellConfig, tokenize };
1042
2052
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map