Keep a place, and come back to it
Restores the commit reverted off the routing branch, which is where it was committed by mistake. The change is unmodified; only its branch is. The map had no memory. Every visit started at the same overview, and a system worth returning to had to be found again by name each time. A mark on the readout and on a body's panel now keeps it, a Bookmarks tab lists what has been kept, and choosing one goes there — a star by flying into its system, a body by opening its page. Local storage, not an account. This map asks nobody to sign in, and a list of stars somebody liked is not worth a server. Every read of that store is defensive, because it is a string a person can edit, another tab can write, and a browser can refuse to hand over at all: a bad entry is skipped rather than losing the rest, duplicates are collapsed since two entries for one place would each toggle the other's control, the list is bounded so a hand-edited store cannot decide how much this renders, and where storage is denied outright the bookmarks still work for the visit — they just do not outlive it. The name is stored alongside the id rather than looked up, so the list reads before the catalogues have loaded, and a bookmark to something a later catalogue no longer holds still says what it was instead of decaying into a bare number. The tab is offered even when it is empty, and says what the mark does: a tab that appears only once you have already found the feature is a tab that never taught anyone anything. Choosing a kept place hands the panel back to the readout, the same move as choosing a search result and for the same reason. That behaviour is what the end-to-end spec caught missing — the readout it asserted on did not exist, because the panel just used was still covering it. Verified: build clean, 587/587 unit, 11/11 end-to-end, design detector clean, screenshots at 1440x900 and 390x844. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
||||
|
||||
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||
import { SearchComponent } from '../search/search.component';
|
||||
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
|
||||
|
||||
@@ -39,9 +41,9 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||
{ key: 'jumpLinks', label: 'Jump links' }
|
||||
];
|
||||
|
||||
export type DockTab = 'search' | 'readout' | 'routes' | 'display';
|
||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||
|
||||
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', display: 'Display' };
|
||||
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
||||
|
||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||
@@ -68,7 +70,7 @@ function isWideViewport(): boolean {
|
||||
@Component({
|
||||
selector: 'app-hud-dock',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RoutesPanelComponent, SearchComponent],
|
||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
||||
template: `
|
||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||
@@ -87,7 +89,21 @@ function isWideViewport(): boolean {
|
||||
@case ('readout') {
|
||||
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
@if (keepableStarId(); as starId) {
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="(bookmarks.has('star', starId) ? 'Forget ' : 'Keep ') + title()"
|
||||
[attr.aria-pressed]="bookmarks.has('star', starId)"
|
||||
(click)="bookmarks.toggle({ kind: 'star', id: starId, name: title() })"
|
||||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="bookmarks.has('star', starId) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||
>
|
||||
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', starId)" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
}
|
||||
@@ -119,6 +135,40 @@ function isWideViewport(): boolean {
|
||||
/>
|
||||
</section>
|
||||
}
|
||||
@case ('bookmarks') {
|
||||
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
|
||||
@if (bookmarks.bookmarks().length) {
|
||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
|
||||
<li class="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
(click)="onBookmarkChosen(bookmark)"
|
||||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
|
||||
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-label]="'Forget ' + bookmark.name"
|
||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M3 3l8 8M11 3l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="px-3 py-3 text-sm text-muted">
|
||||
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@case ('display') {
|
||||
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||
<p class="type-label text-muted">Layers</p>
|
||||
@@ -187,6 +237,8 @@ export class HudDockComponent implements OnInit {
|
||||
readonly routeResult = input<RouteResult | null>(null);
|
||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||||
readonly keepableStarId = input<number | null>(null);
|
||||
/** Present makes the Routes tab available; absent means this surface cannot route. */
|
||||
readonly routing = input(false);
|
||||
|
||||
@@ -195,6 +247,7 @@ export class HudDockComponent implements OnInit {
|
||||
readonly routeRequested = output<RouteRequest>();
|
||||
readonly routeStarSelected = output<number>();
|
||||
readonly jumpRangeChange = output<number>();
|
||||
readonly bookmarkChosen = output<Bookmark>();
|
||||
|
||||
readonly layers = DISPLAY_LAYERS;
|
||||
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
||||
@@ -202,11 +255,16 @@ export class HudDockComponent implements OnInit {
|
||||
'search',
|
||||
...(this.title() ? (['readout'] as const) : []),
|
||||
...(this.routing() ? (['routes'] as const) : []),
|
||||
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||
'bookmarks',
|
||||
...(this.display() ? (['display'] as const) : [])
|
||||
]);
|
||||
|
||||
readonly activeTab = signal<DockTab | null>(null);
|
||||
|
||||
readonly bookmarks = inject(BookmarksStore);
|
||||
|
||||
private readonly search = viewChild(SearchComponent);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
@@ -238,6 +296,13 @@ export class HudDockComponent implements OnInit {
|
||||
this.routeStarSelected.emit(starId);
|
||||
}
|
||||
|
||||
/** Choosing a kept place is the same move as choosing a search result: the panel has done
|
||||
* its job and the thing to look at is now the scene. */
|
||||
onBookmarkChosen(bookmark: Bookmark): void {
|
||||
this.bookmarkChosen.emit(bookmark);
|
||||
this.onPicked();
|
||||
}
|
||||
|
||||
onPicked(): void {
|
||||
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
|
||||
// readout where there is one, and fold the sheet away where there is not. On a narrow
|
||||
|
||||
Reference in New Issue
Block a user