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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user