feat(page-help): genie animation + breadcrumb badge + dismiss menu

Page help panel now animates in/out with a genie effect. A persistent
help badge in the breadcrumb lets users reopen the panel after closing.
Long-press on close reveals per-page and global dismiss options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 15:33:40 +03:00
parent 8beed2afb4
commit 1e84d98413
3 changed files with 583 additions and 202 deletions

View File

@@ -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 {
}
</li>
}
@if (helpBadge.visible()) {
<li class="breadcrumb__item breadcrumb__item--badge">
<button
class="breadcrumb__help-badge"
[class.breadcrumb__help-badge--open]="helpBadge.panelOpen()"
[class.breadcrumb__help-badge--attention]="helpBadge.justClosed()"
(click)="helpBadge.toggle()"
[title]="helpBadge.panelOpen() ? 'Hide page tips' : 'Show page tips'"
[attr.aria-label]="helpBadge.panelOpen() ? 'Hide page tips' : 'Show page tips'"
[attr.aria-pressed]="helpBadge.panelOpen()"
>
<svg class="breadcrumb__help-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@if (helpBadge.panelOpen()) {
<circle cx="12" cy="12" r="10" fill="none" stroke="var(--color-brand-primary, #F5A623)" stroke-width="2"/>
<text x="12" y="17" text-anchor="middle" fill="var(--color-brand-primary, #F5A623)" font-size="14" font-weight="700" font-family="system-ui">?</text>
} @else {
<circle cx="12" cy="12" r="10" fill="var(--color-brand-primary, #F5A623)" stroke="none"/>
<text x="12" y="17" text-anchor="middle" fill="white" font-size="14" font-weight="700" font-family="system-ui">?</text>
}
</svg>
@if (!helpBadge.panelOpen() && helpBadge.labelVisible()) {
<span class="breadcrumb__help-label">Page tips</span>
}
</button>
</li>
}
</ol>
</nav>
}
@@ -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;

View File

@@ -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<typeof setTimeout> | null = null;
private labelTimer: ReturnType<typeof setTimeout> | 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?.();
}
}

View File

@@ -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) {
<section class="page-help" [attr.data-page-help]="pageKey()">
<button
type="button"
class="page-help__toggle"
[attr.aria-expanded]="isOpen()"
(click)="toggle()"
>
<div class="page-help__toggle-copy">
<span class="page-help__eyebrow">About this page</span>
<strong>{{ helpContent.title }}</strong>
</div>
<span class="page-help__toggle-state">{{ isOpen() ? 'Hide' : 'Show' }}</span>
</button>
@if (isOpen()) {
<div class="page-help__body">
<div class="page-help__intro">
<p class="page-help__overview">{{ helpContent.overview }}</p>
@if ((isOpen() || minimizing() || dismissing()) && help(); as helpContent) {
<section
class="page-help"
[class.page-help--minimizing]="minimizing()"
[class.page-help--expanding]="expanding()"
[attr.data-page-help]="pageKey()"
>
<div class="page-help__header">
<button
type="button"
class="page-help__toggle"
[attr.aria-expanded]="isOpen()"
(click)="toggle()"
>
<div class="page-help__toggle-copy">
<span class="page-help__eyebrow">About this page</span>
<strong>{{ helpContent.title }}</strong>
</div>
</button>
@if (helpContent.topics.length > 0) {
<section class="page-help__section">
<h3>Key concepts</h3>
<div class="page-help__topic-grid">
@for (topic of helpContent.topics; track topic.title) {
<article class="page-help__topic-card">
<strong>{{ topic.title }}</strong>
<p>{{ topic.description }}</p>
</article>
}
</div>
</section>
<!-- Close button: quick click = hide for now, hold 3s = show menu -->
<div class="page-help__dismiss-wrap">
<button
class="page-help__dismiss-btn"
[class.page-help__dismiss-btn--holding]="holding()"
(mousedown)="onCloseDown($event)"
(touchstart)="onCloseDown($event)"
title="Click to close · Hold for options"
aria-label="Close tutorial"
[attr.aria-expanded]="dismissMenuOpen()"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<!-- Progress ring for long-press feedback -->
@if (holding()) {
<svg class="page-help__hold-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="16" fill="none" stroke="var(--color-brand-primary, #F5A623)"
stroke-width="3" stroke-dasharray="100.5" stroke-dashoffset="100.5"
stroke-linecap="round" />
</svg>
}
</button>
@if (dismissMenuOpen()) {
<div class="page-help__dismiss-menu" role="menu">
<button class="page-help__dismiss-opt" role="menuitem" (click)="onDismissPage()">
<span class="page-help__dismiss-label">Hide on this page</span>
<span class="page-help__dismiss-desc">Permanently on this page only</span>
</button>
<div class="page-help__dismiss-sep"></div>
<button class="page-help__dismiss-opt page-help__dismiss-opt--danger" role="menuitem" (click)="onDismissAll()">
<span class="page-help__dismiss-label">Hide on all pages</span>
<span class="page-help__dismiss-desc">Disable tutorials everywhere</span>
</button>
</div>
}
</div>
</div>
@if (helpContent.actions.length > 0) {
<section class="page-help__section">
<h3>Common actions</h3>
<div class="page-help__actions">
@for (action of helpContent.actions; track action.label + action.route) {
<a [routerLink]="action.route" class="page-help__action">{{ action.label }}</a>
}
</div>
</section>
}
@if (helpContent.example) {
<section class="page-help__section">
<h3>{{ helpContent.exampleTitle || 'Example' }}</h3>
<pre class="page-help__example">{{ helpContent.example }}</pre>
</section>
}
<div class="page-help__body">
<div class="page-help__intro">
<p class="page-help__overview">{{ helpContent.overview }}</p>
</div>
@if (helpContent.topics.length > 0) {
<section class="page-help__section">
<h3>Docs</h3>
<div class="page-help__docs">
@for (doc of helpContent.docs; track doc.label + doc.route) {
<a [routerLink]="doc.route" class="page-help__doc-link">{{ doc.label }}</a>
<h3>Key concepts</h3>
<div class="page-help__topic-grid">
@for (topic of helpContent.topics; track topic.title) {
<article class="page-help__topic-card">
<strong>{{ topic.title }}</strong>
<p>{{ topic.description }}</p>
</article>
}
</div>
</section>
</div>
}
}
@if (helpContent.actions.length > 0) {
<section class="page-help__section">
<h3>Common actions</h3>
<div class="page-help__actions">
@for (action of helpContent.actions; track action.label + action.route) {
<a [routerLink]="action.route" class="page-help__action">{{ action.label }}</a>
}
</div>
</section>
}
@if (helpContent.example) {
<section class="page-help__section">
<h3>{{ helpContent.exampleTitle || 'Example' }}</h3>
<pre class="page-help__example">{{ helpContent.example }}</pre>
</section>
}
<section class="page-help__section">
<h3>Docs</h3>
<div class="page-help__docs">
@for (doc of helpContent.docs; track doc.label + doc.route) {
<a [routerLink]="doc.route" class="page-help__doc-link">{{ doc.label }}</a>
}
</div>
</section>
</div>
</section>
}
`,
@@ -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<string | null>(null);
private readonly badge = inject(PageHelpBadgeService);
private readonly initializedPageKey = signal<string | null>(null);
readonly dismissMenuOpen = signal(false);
readonly minimizing = signal(false);
readonly dismissing = signal(false);
readonly expanding = signal(false);
readonly holding = signal(false);
private holdTimer: ReturnType<typeof setTimeout> | 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);
}
}