-
{{ helpContent.overview }}
+ @if ((isOpen() || minimizing() || dismissing()) && help(); as helpContent) {
+
+
- @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);
}
}
-