diff --git a/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts b/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts index 3907185ce..c2c4d6e9e 100644 --- a/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/breadcrumb/breadcrumb.component.ts @@ -11,6 +11,7 @@ import { import { Router, NavigationEnd, ActivatedRoute, RouterLink } from '@angular/router'; import { filter, map } from 'rxjs/operators'; import { Subscription } from 'rxjs'; +import { PageHelpBadgeService } from '../../shared/components/page-help/page-help-badge.service'; /** * Breadcrumb item structure. @@ -82,6 +83,33 @@ export class BreadcrumbService { } } + + @if (helpBadge.visible()) { + + } } @@ -147,6 +175,87 @@ export class BreadcrumbService { color: var(--color-text-muted); opacity: 0.5; } + + .breadcrumb__help-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + min-width: 20px; + height: 22px; + padding: 0 0.35rem 0 0.15rem; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + cursor: pointer; + transition: transform 0.2s, filter 0.2s, opacity 0.2s, background 0.2s, border-color 0.2s; + filter: drop-shadow(0 1px 2px rgba(245, 166, 35, 0.3)); + } + + .breadcrumb__help-icon { flex-shrink: 0; } + + .breadcrumb__help-badge:hover { + transform: scale(1.08); + filter: drop-shadow(0 2px 5px rgba(245, 166, 35, 0.5)); + background: color-mix(in srgb, var(--color-brand-primary, #F5A623) 8%, transparent); + border-color: color-mix(in srgb, var(--color-brand-primary, #F5A623) 25%, transparent); + } + + .breadcrumb__help-badge:active { + transform: scale(0.95); + } + + /* Open state: compact circle, subtle */ + .breadcrumb__help-badge--open { + opacity: 0.6; + padding: 0; + width: 20px; + } + + /* "Catch" animation after the panel closes — draws the eye to the badge */ + .breadcrumb__help-badge--attention { + animation: badge-catch 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both; + } + + @keyframes badge-catch { + 0% { transform: scale(1); + filter: drop-shadow(0 0 0 rgba(245, 166, 35, 0)); + background: transparent; border-color: transparent; } + 25% { transform: scale(1.35); + filter: drop-shadow(0 0 10px rgba(245, 166, 35, 0.7)); + background: color-mix(in srgb, var(--color-brand-primary, #F5A623) 14%, transparent); + border-color: color-mix(in srgb, var(--color-brand-primary, #F5A623) 40%, transparent); } + 55% { transform: scale(1.1); + filter: drop-shadow(0 0 6px rgba(245, 166, 35, 0.4)); } + 100% { transform: scale(1); + filter: drop-shadow(0 1px 2px rgba(245, 166, 35, 0.3)); + background: transparent; border-color: transparent; } + } + + /* "Page tips" label — appears on close, auto-fades over 10s */ + .breadcrumb__help-label { + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; + animation: label-auto-hide 10s ease forwards; + } + + @keyframes label-auto-hide { + 0% { opacity: 0; transform: translateX(-3px); color: var(--color-brand-primary, #F5A623); } + 8% { opacity: 0.8; transform: translateX(0); color: var(--color-brand-primary, #F5A623); } + 25% { opacity: 0.65; color: var(--color-text-secondary); } + 80% { opacity: 0.65; } + 100% { opacity: 0; } + } + + .breadcrumb__item--badge { + margin-left: 0.35rem; + } + + @media (prefers-reduced-motion: reduce) { + .breadcrumb__help-badge--attention { animation: none !important; } + .breadcrumb__help-label { animation: none !important; opacity: 0.65; color: var(--color-text-secondary); } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -154,6 +263,7 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly breadcrumbService = inject(BreadcrumbService); + readonly helpBadge = inject(PageHelpBadgeService); private subscription?: Subscription; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-badge.service.ts b/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-badge.service.ts new file mode 100644 index 000000000..a8afc2d0a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-badge.service.ts @@ -0,0 +1,56 @@ +import { Injectable, signal } from '@angular/core'; + +/** + * Bridges page-help panel state to the breadcrumb component. + * The badge is always visible when help content exists; clicking it toggles the panel. + */ +@Injectable({ providedIn: 'root' }) +export class PageHelpBadgeService { + /** Whether help content exists for the current page */ + readonly visible = signal(false); + /** Whether the tutorial panel is currently open */ + readonly panelOpen = signal(false); + readonly title = signal(''); + /** Brief attention flag — true for ~1.5 s after the panel closes */ + readonly justClosed = signal(false); + /** Label visible for ~10 s after close, then auto-hides */ + readonly labelVisible = signal(false); + + private toggleFn: (() => void) | null = null; + private closeTimer: ReturnType | null = null; + private labelTimer: ReturnType | null = null; + + /** Called by PageHelpPanelComponent on every page / state change */ + sync(title: string, panelOpen: boolean, toggleFn: () => void): void { + this.title.set(title); + this.panelOpen.set(panelOpen); + this.visible.set(true); + this.toggleFn = toggleFn; + } + + /** No help content on this page */ + hide(): void { + this.visible.set(false); + this.panelOpen.set(false); + this.justClosed.set(false); + this.labelVisible.set(false); + this.toggleFn = null; + } + + /** Called when the panel closes — triggers the badge attention animation + label */ + notifyClosed(): void { + this.justClosed.set(true); + this.labelVisible.set(true); + + if (this.closeTimer) clearTimeout(this.closeTimer); + this.closeTimer = setTimeout(() => this.justClosed.set(false), 1500); + + if (this.labelTimer) clearTimeout(this.labelTimer); + this.labelTimer = setTimeout(() => this.labelVisible.set(false), 10_000); + } + + /** Breadcrumb badge click — toggle the panel */ + toggle(): void { + this.toggleFn?.(); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-panel.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-panel.component.ts index af6aedd14..22527fa11 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/page-help/page-help-panel.component.ts @@ -1,10 +1,19 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + HostListener, + inject, + signal, +} from '@angular/core'; import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { filter, map, startWith } from 'rxjs/operators'; import { StellaPreferencesService } from '../stella-helper/stella-preferences.service'; import { getPageHelpContentForUrl } from './page-help-content'; import { resolvePageKey } from '../stella-helper/stella-helper-tips.config'; +import { PageHelpBadgeService } from './page-help-badge.service'; function normalizeHelpStateKey(url: string): string | null { const normalized = url @@ -16,75 +25,120 @@ function normalizeHelpStateKey(url: string): string | null { return normalized ? `route:${normalized}` : null; } +const LONG_PRESS_MS = 500; + @Component({ selector: 'app-page-help-panel', standalone: true, imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - @if (help(); as helpContent) { -
- - - @if (isOpen()) { -
-
-

{{ helpContent.overview }}

+ @if ((isOpen() || minimizing() || dismissing()) && help(); as helpContent) { +
+
+ - @if (helpContent.topics.length > 0) { -
-

Key concepts

-
- @for (topic of helpContent.topics; track topic.title) { -
- {{ topic.title }} -

{{ topic.description }}

-
- } -
-
+ +
+ + @if (dismissMenuOpen()) { + } +
+
- @if (helpContent.actions.length > 0) { -
-

Common actions

-
- @for (action of helpContent.actions; track action.label + action.route) { - {{ action.label }} - } -
-
- } - - @if (helpContent.example) { -
-

{{ helpContent.exampleTitle || 'Example' }}

-
{{ helpContent.example }}
-
- } +
+
+

{{ helpContent.overview }}

+
+ @if (helpContent.topics.length > 0) {
-

Docs

-
- @for (doc of helpContent.docs; track doc.label + doc.route) { - {{ doc.label }} +

Key concepts

+
+ @for (topic of helpContent.topics; track topic.title) { +
+ {{ topic.title }} +

{{ topic.description }}

+
}
-
- } + } + + @if (helpContent.actions.length > 0) { +
+

Common actions

+
+ @for (action of helpContent.actions; track action.label + action.route) { + {{ action.label }} + } +
+
+ } + + @if (helpContent.example) { +
+

{{ helpContent.exampleTitle || 'Example' }}

+
{{ helpContent.example }}
+
+ } + +
+

Docs

+
+ @for (doc of helpContent.docs; track doc.label + doc.route) { + {{ doc.label }} + } +
+
+
} `, @@ -94,146 +148,175 @@ function normalizeHelpStateKey(url: string): string | null { border: 1px solid color-mix(in srgb, var(--color-border-primary) 70%, transparent); border-radius: 1rem; background: - radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand-primary, #2563eb) 10%, transparent), transparent 42%), - linear-gradient(180deg, color-mix(in srgb, var(--color-surface-primary) 92%, var(--color-brand-primary, #2563eb) 8%), var(--color-surface-primary)); + radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand-primary, #F5A623) 10%, transparent), transparent 42%), + linear-gradient(180deg, color-mix(in srgb, var(--color-surface-primary) 92%, var(--color-brand-primary, #F5A623) 8%), var(--color-surface-primary)); overflow: hidden; + transform-origin: top left; } + /* ===== Genie out ===== */ + .page-help--minimizing { + animation: page-help-genie-out 0.5s cubic-bezier(0.4, 0, 0.6, 1) forwards; + pointer-events: none; + } + + @keyframes page-help-genie-out { + 0% { transform: scale(1, 1) translateY(0); opacity: 1; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); } + 35% { transform: scale(0.6, 0.4) translateY(-30px); opacity: 0.8; + clip-path: polygon(0% 0%, 70% 0%, 80% 100%, 0% 100%); } + 70% { transform: scale(0.2, 0.1) translateY(-50px); opacity: 0.4; + clip-path: polygon(0% 0%, 30% 0%, 40% 100%, 0% 100%); } + 100% { transform: scale(0.03, 0.01) translateY(-60px); opacity: 0; + clip-path: polygon(0% 0%, 6% 0%, 8% 100%, 0% 100%); + max-height: 0; margin: 0; padding: 0; border-width: 0; } + } + + /* ===== Genie in ===== */ + .page-help--expanding { + animation: page-help-genie-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + @keyframes page-help-genie-in { + 0% { transform: scale(0.03, 0.01) translateY(-60px); opacity: 0; + clip-path: polygon(0% 0%, 6% 0%, 8% 100%, 0% 100%); } + 30% { transform: scale(0.2, 0.12) translateY(-40px); opacity: 0.5; + clip-path: polygon(0% 0%, 35% 0%, 45% 100%, 0% 100%); } + 65% { transform: scale(0.7, 0.6) translateY(-10px); opacity: 0.85; + clip-path: polygon(0% 0%, 80% 0%, 90% 100%, 0% 100%); } + 100% { transform: scale(1, 1) translateY(0); opacity: 1; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); } + } + + /* ===== Header ===== */ + .page-help__header { display: flex; align-items: center; } + .page-help__toggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.95rem 1.05rem; - border: none; - background: transparent; - color: inherit; - cursor: pointer; - text-align: left; + flex: 1; display: flex; align-items: center; gap: 1rem; + padding: 0.95rem 1.05rem; border: none; background: transparent; + color: inherit; cursor: pointer; text-align: left; } - - .page-help__toggle:hover { - background: color-mix(in srgb, var(--color-surface-secondary) 78%, transparent); - } - - .page-help__toggle-copy { - display: grid; - gap: 0.18rem; - } - + .page-help__toggle:hover { background: color-mix(in srgb, var(--color-surface-secondary) 78%, transparent); } + .page-help__toggle-copy { display: grid; gap: 0.18rem; } .page-help__eyebrow { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-text-secondary); + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--color-text-secondary); } - .page-help__toggle-state { - font-size: 0.82rem; - color: var(--color-text-secondary); - white-space: nowrap; + /* ===== Close button with long-press ===== */ + .page-help__dismiss-wrap { position: relative; flex-shrink: 0; padding-right: 0.75rem; } + + .page-help__dismiss-btn { + position: relative; + display: flex; align-items: center; justify-content: center; + width: 28px; height: 28px; + border: 1px solid color-mix(in srgb, var(--color-border-primary) 60%, transparent); + border-radius: 50%; background: transparent; + color: var(--color-text-secondary); cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + -webkit-user-select: none; user-select: none; + } + .page-help__dismiss-btn:hover { + background: var(--color-surface-secondary); + color: var(--color-text-primary); border-color: var(--color-border-primary); } + /* Progress ring around close button during hold */ + .page-help__hold-ring { + position: absolute; + top: 50%; left: 50%; + width: 36px; height: 36px; + transform: translate(-50%, -50%) rotate(-90deg); + pointer-events: none; + } + .page-help__hold-ring circle { + animation: hold-ring-fill 0.5s linear forwards; + } + @keyframes hold-ring-fill { + 0% { stroke-dashoffset: 100.5; } + 100% { stroke-dashoffset: 0; } + } + + .page-help__dismiss-btn--holding { + border-color: var(--color-brand-primary, #F5A623); + } + + /* ===== Dismiss menu ===== */ + .page-help__dismiss-menu { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; + min-width: 220px; padding: 0.35rem 0; + border: 1px solid var(--color-border-primary); border-radius: 0.65rem; + background: var(--color-surface-primary); + box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06); + animation: dismiss-menu-enter 0.15s ease-out both; + } + @keyframes dismiss-menu-enter { + 0% { opacity: 0; transform: translateY(-4px) scale(0.96); } + 100% { opacity: 1; transform: translateY(0) scale(1); } + } + .page-help__dismiss-opt { + display: grid; gap: 0.1rem; width: 100%; padding: 0.55rem 0.85rem; + border: none; background: transparent; color: inherit; cursor: pointer; text-align: left; + } + .page-help__dismiss-opt:hover { background: var(--color-surface-secondary); } + .page-help__dismiss-label { font-size: 0.84rem; font-weight: 600; } + .page-help__dismiss-desc { font-size: 0.74rem; color: var(--color-text-secondary); } + .page-help__dismiss-sep { margin: 0.25rem 0; border-top: 1px solid var(--color-border-primary); } + .page-help__dismiss-opt--danger .page-help__dismiss-label { color: var(--color-danger, #d32f2f); } + + /* ===== Body ===== */ .page-help__body { - display: grid; - gap: 1rem; - padding: 0 1.05rem 1.05rem; + display: grid; gap: 1rem; padding: 0 1.05rem 1.05rem; border-top: 1px solid color-mix(in srgb, var(--color-border-primary) 55%, transparent); } - - .page-help__overview { - margin: 0; - color: var(--color-text-secondary); - line-height: 1.55; - } - - .page-help__section { - display: grid; - gap: 0.65rem; - } - - .page-help__section h3 { - margin: 0; - font-size: 0.9rem; - color: var(--color-text-heading, var(--color-text-primary)); - } - - .page-help__topic-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.75rem; - } - + .page-help__overview { margin: 0; color: var(--color-text-secondary); line-height: 1.55; } + .page-help__section { display: grid; gap: 0.65rem; } + .page-help__section h3 { margin: 0; font-size: 0.9rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-help__topic-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; } .page-help__topic-card { - display: grid; - gap: 0.35rem; - padding: 0.8rem 0.9rem; - border-radius: 0.9rem; + display: grid; gap: 0.35rem; padding: 0.8rem 0.9rem; border-radius: 0.9rem; border: 1px solid color-mix(in srgb, var(--color-border-primary) 70%, transparent); background: color-mix(in srgb, var(--color-surface-primary) 86%, transparent); } - - .page-help__topic-card p { - margin: 0; - color: var(--color-text-secondary); - line-height: 1.5; - font-size: 0.88rem; - } - - .page-help__actions, - .page-help__docs { - display: flex; - flex-wrap: wrap; - gap: 0.65rem; - } - - .page-help__action, - .page-help__doc-link { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.55rem 0.85rem; - border-radius: 999px; + .page-help__topic-card p { margin: 0; color: var(--color-text-secondary); line-height: 1.5; font-size: 0.88rem; } + .page-help__actions, .page-help__docs { display: flex; flex-wrap: wrap; gap: 0.65rem; } + .page-help__action, .page-help__doc-link { + display: inline-flex; align-items: center; justify-content: center; + padding: 0.55rem 0.85rem; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--color-border-primary) 78%, transparent); background: color-mix(in srgb, var(--color-surface-primary) 92%, transparent); - color: var(--color-text-link, var(--color-brand-primary)); - text-decoration: none; - font-size: 0.84rem; - font-weight: 600; + color: var(--color-text-link, var(--color-brand-primary)); text-decoration: none; font-size: 0.84rem; font-weight: 600; } - .page-help__example { - margin: 0; - padding: 0.8rem 0.9rem; - border-radius: 0.9rem; - border: 1px dashed color-mix(in srgb, var(--color-brand-primary, #2563eb) 28%, var(--color-border-primary)); - background: color-mix(in srgb, var(--color-surface-primary) 92%, var(--color-brand-primary, #2563eb) 8%); - color: var(--color-text-primary); - font-size: 0.84rem; - line-height: 1.55; - white-space: pre-wrap; + margin: 0; padding: 0.8rem 0.9rem; border-radius: 0.9rem; + border: 1px dashed color-mix(in srgb, var(--color-brand-primary, #F5A623) 28%, var(--color-border-primary)); + background: color-mix(in srgb, var(--color-surface-primary) 92%, var(--color-brand-primary, #F5A623) 8%); + color: var(--color-text-primary); font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; } - - @media (max-width: 700px) { - .page-help__toggle { - align-items: flex-start; - } - - .page-help__toggle-state { - padding-top: 0.15rem; - } + @media (max-width: 700px) { .page-help__toggle { align-items: flex-start; } } + @media (prefers-reduced-motion: reduce) { + .page-help--minimizing, .page-help--expanding { animation: none !important; } + .page-help__hold-ring circle { animation: none !important; } } `], }) export class PageHelpPanelComponent { private readonly router = inject(Router); private readonly prefs = inject(StellaPreferencesService); - private readonly cdr = inject(ChangeDetectorRef); - private readonly firstVisitPage = signal(null); + private readonly badge = inject(PageHelpBadgeService); private readonly initializedPageKey = signal(null); + readonly dismissMenuOpen = signal(false); + readonly minimizing = signal(false); + readonly dismissing = signal(false); + readonly expanding = signal(false); + readonly holding = signal(false); + + private holdTimer: ReturnType | null = null; + private holdStart = 0; + private menuJustOpened = false; + private readonly boundGlobalUp = (e: Event) => this.onGlobalUp(e); + private readonly currentUrl = toSignal( this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd), @@ -246,61 +329,193 @@ export class PageHelpPanelComponent { readonly pageKey = computed(() => resolvePageKey(this.currentUrl())); readonly help = computed(() => getPageHelpContentForUrl(this.currentUrl())); readonly helpStateKey = computed(() => { - if (!this.help()) { - return null; - } - - const pageKey = this.pageKey(); - return pageKey === 'default' ? normalizeHelpStateKey(this.currentUrl()) : pageKey; + if (!this.help()) return null; + const pk = this.pageKey(); + return pk === 'default' ? normalizeHelpStateKey(this.currentUrl()) : pk; }); + readonly isOpen = computed(() => { + if (this.dismissing()) return false; + const key = this.helpStateKey(); - if (!key || this.help() === null) { - return false; + if (!key || this.help() === null) return false; + + if (this.prefs.isPageHelpDismissed(key)) { + return this.prefs.prefs().pageHelpOpen[key] === true; } - const explicit = this.prefs.prefs().pageHelpOpen[key]; - if (typeof explicit === 'boolean') { - return explicit; - } + if (this.prefs.prefs().pageHelpOpen[key] === false) return false; - return this.firstVisitPage() === key || !this.prefs.isPageHelpSeen(key); + return true; }); constructor() { + // Sync badge + state on page navigation effect(() => { const key = this.helpStateKey(); - if (key && this.help()) { - if (this.initializedPageKey() !== key) { - const seen = this.prefs.isPageHelpSeen(key); - this.firstVisitPage.set(seen ? null : key); - this.initializedPageKey.set(key); - if (!seen) { - this.prefs.markPageHelpSeen(key); - } - } - } else { - this.firstVisitPage.set(null); - this.initializedPageKey.set(null); - } + const help = this.help(); - queueMicrotask(() => { - try { - this.cdr.detectChanges(); - } catch { - // Ignore if the component was destroyed before the queued refresh runs. + if (key && help) { + if (this.initializedPageKey() !== key) { + this.initializedPageKey.set(key); + this.minimizing.set(false); + this.dismissing.set(false); + this.dismissMenuOpen.set(false); + this.holding.set(false); } - }); + // Always sync badge (it's always visible when help exists) + this.badge.sync(help.title, this.isOpen(), () => this.toggle()); + } else { + this.initializedPageKey.set(null); + this.badge.hide(); + } }); } - toggle(): void { - const key = this.helpStateKey(); - if (!key) { + // ---- Close button: quick click vs long press ---- + + onCloseDown(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + this.holdStart = Date.now(); + this.holding.set(true); + + // Listen globally for release — works even if mouse leaves the button + document.addEventListener('mouseup', this.boundGlobalUp, { once: true }); + document.addEventListener('touchend', this.boundGlobalUp, { once: true }); + + this.holdTimer = setTimeout(() => { + // Long press reached — show permanent dismiss options + this.holding.set(false); + this.dismissMenuOpen.set(true); + this.menuJustOpened = true; + }, LONG_PRESS_MS); + } + + private onGlobalUp(e: Event): void { + e.preventDefault(); + const elapsed = Date.now() - this.holdStart; + this.clearHold(); + + // Remove the other listener (whichever didn't fire) + document.removeEventListener('mouseup', this.boundGlobalUp); + document.removeEventListener('touchend', this.boundGlobalUp); + + if (this.dismissMenuOpen()) { + // Menu was opened by long-press — keep it open. + // Block the next document click from closing it. + setTimeout(() => this.menuJustOpened = false, 100); return; } - this.prefs.setPageHelpOpen(key, !this.isOpen()); + if (elapsed < LONG_PRESS_MS) { + // Quick click — instant "hide for now" + this.onDismissNow(); + } + } + + private clearHold(): void { + if (this.holdTimer) { + clearTimeout(this.holdTimer); + this.holdTimer = null; + } + this.holding.set(false); + } + + // ---- Toggle (used by breadcrumb badge and header click) ---- + + toggle(): void { + const key = this.helpStateKey(); + if (!key) return; + + if (this.isOpen()) { + this.doClose(key); + } else { + this.doOpen(key); + } + } + + // ---- Dismiss tiers ---- + + private onDismissNow(): void { + const key = this.helpStateKey(); + if (!key) return; + this.doClose(key); + } + + onDismissPage(): void { + const key = this.helpStateKey(); + if (!key) return; + this.dismissMenuOpen.set(false); + this.animateMinimize(() => { + this.prefs.setPageHelpOpen(key, false); + this.prefs.dismissPageHelpForPage(key); + }); + } + + onDismissAll(): void { + const key = this.helpStateKey(); + if (!key) return; + this.dismissMenuOpen.set(false); + this.animateMinimize(() => { + this.prefs.setPageHelpOpen(key, false); + this.prefs.dismissPageHelpGlobal(); + }); + } + + // ---- Open / Close with animation ---- + + private doClose(key: string): void { + this.dismissMenuOpen.set(false); + this.animateMinimize(() => { + this.prefs.setPageHelpOpen(key, false); + }); + } + + private doOpen(key: string): void { + this.prefs.setPageHelpOpen(key, true); + this.syncBadge(); + + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.expanding.set(true); + setTimeout(() => this.expanding.set(false), 450); + } + } + + @HostListener('document:click') + onDocumentClick(): void { + if (this.menuJustOpened) return; + if (this.dismissMenuOpen()) { + this.dismissMenuOpen.set(false); + } + } + + private syncBadge(): void { + const help = this.help(); + if (help) { + this.badge.sync(help.title, this.isOpen(), () => this.toggle()); + } + } + + private animateMinimize(afterFn: () => void): void { + this.dismissing.set(true); + + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReduced) { + afterFn(); + this.dismissing.set(false); + this.syncBadge(); + this.badge.notifyClosed(); + return; + } + + this.minimizing.set(true); + setTimeout(() => { + afterFn(); + this.minimizing.set(false); + this.dismissing.set(false); + this.syncBadge(); + this.badge.notifyClosed(); + }, 500); } } -