From c8fa9fd2e9cd7fcdd2cf7d881f83a0f95eadcf3c Mon Sep 17 00:00:00 2001 From: dame Date: Fri, 22 May 2026 16:16:16 -0700 Subject: [PATCH] Surface newly-added built-in waypoints in the extension popup (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surface newly-added built-in waypoints in the extension popup Track which built-in waypoint ids the user has already seen via a new `knownWaypointIds` array in prefs. On upgrade, seed it from the user's existing groups (and legacy hiddenWaypoints) so anything added to the catalog later — e.g. Bluepy for users on pre-0.1.2 installs — shows up as a dismissable "new waypoints" banner in the popup footer. Also replace the footer Settings button with a dark/light theme toggle; the gear in the header still opens settings. Add a "new" tag in the Waypoints picker and group rows so new built-ins are easy to spot. * Inline atptools wrench icon to unblock extension build The extension imports the shared waypoint icon catalog through the `@aturi` Vite alias, which points at `src/utils/`. Rolldown resolves bare specifiers from the importer's directory upwards, so `lucide-react` was unreachable from there even though it lives in the extension's own `node_modules`. The Next.js app finds it via the parent's `node_modules`, which masked the issue locally. Drop the `lucide-react` import from waypointIcons.tsx and inline the Wrench mark as an SVG component. One less third-party dep on a file that's shared by two builds with two different resolution roots. --------- Co-authored-by: Claude --- extension/entrypoints/options/App.tsx | 28 ++- extension/entrypoints/options/options.css | 6 + .../options/tabs/VisibilityTab.tsx | 23 ++- extension/entrypoints/popup/App.tsx | 163 +++++++++++++++--- extension/entrypoints/popup/popup.css | 111 +++++++++++- extension/lib/__tests__/prefs-known.test.ts | 83 +++++++++ extension/lib/catalog.ts | 16 ++ extension/lib/prefs.ts | 85 ++++++++- src/utils/waypointIcons.tsx | 23 ++- 9 files changed, 508 insertions(+), 30 deletions(-) create mode 100644 extension/lib/__tests__/prefs-known.test.ts diff --git a/extension/entrypoints/options/App.tsx b/extension/entrypoints/options/App.tsx index a435859..d75585d 100644 --- a/extension/entrypoints/options/App.tsx +++ b/extension/entrypoints/options/App.tsx @@ -34,9 +34,26 @@ const TABS: { id: TabId; label: string }[] = [ { id: 'about', label: 'About' }, ]; +// Map URL hash → tab id. Lets the popup deep-link to Settings → Waypoints when +// the user clicks "Add" on the new-waypoints banner. +const HASH_TO_TAB: Record = { + general: 'defaults', + defaults: 'defaults', + waypoints: 'visibility', + visibility: 'visibility', + custom: 'custom', + about: 'about', +}; + +function tabFromHash(): TabId | null { + if (typeof window === 'undefined') return null; + const hash = window.location.hash.replace(/^#/, '').toLowerCase(); + return HASH_TO_TAB[hash] ?? null; +} + export default function App() { const [prefs, setPrefs] = useState(null); - const [tab, setTab] = useState('defaults'); + const [tab, setTab] = useState(() => tabFromHash() ?? 'defaults'); useEffect(() => { void loadPrefs().then(setPrefs); @@ -44,6 +61,15 @@ export default function App() { return unsub; }, []); + useEffect(() => { + function onHashChange() { + const next = tabFromHash(); + if (next) setTab(next); + } + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + useEffect(() => { if (prefs) applyAppearance(prefs); }, [prefs?.theme, prefs?.fontSize]); diff --git a/extension/entrypoints/options/options.css b/extension/entrypoints/options/options.css index 952fb62..5ddd5dc 100644 --- a/extension/entrypoints/options/options.css +++ b/extension/entrypoints/options/options.css @@ -575,6 +575,12 @@ body { background: var(--glow-subtle); } +.reorder-tag-new { + color: var(--bg-primary); + border-color: var(--text-accent); + background: var(--text-accent); +} + /* Group-based waypoint organizer (Waypoints tab) */ .group-list { diff --git a/extension/entrypoints/options/tabs/VisibilityTab.tsx b/extension/entrypoints/options/tabs/VisibilityTab.tsx index 57f47f0..7486292 100644 --- a/extension/entrypoints/options/tabs/VisibilityTab.tsx +++ b/extension/entrypoints/options/tabs/VisibilityTab.tsx @@ -36,7 +36,7 @@ import { type Prefs, type WaypointGroup, } from '../../../lib/prefs'; -import { allWaypoints } from '../../../lib/catalog'; +import { allWaypoints, newBuiltinWaypoints } from '../../../lib/catalog'; type Props = { prefs: Prefs; @@ -53,6 +53,11 @@ export default function VisibilityTab({ prefs, onChange }: Props) { return m; }, [allWps]); + const newIds = useMemo( + () => new Set(newBuiltinWaypoints(prefs).map(w => w.id)), + [prefs.knownWaypointIds] + ); + const groupSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -165,6 +170,7 @@ export default function VisibilityTab({ prefs, onChange }: Props) { group={group} lookup={lookup} allWaypoints={allWps} + newIds={newIds} onRename={name => handleRenameGroup(group.id, name)} onRemove={() => handleRemoveGroup(group.id)} onToggleCollapsed={collapsed => handleToggleCollapsed(group.id, collapsed)} @@ -197,6 +203,7 @@ type SortableGroupProps = { group: WaypointGroup; lookup: Map; allWaypoints: WaypointData[]; + newIds: Set; onRename: (name: string) => void; onRemove: () => void; onToggleCollapsed: (collapsed: boolean) => void; @@ -209,6 +216,7 @@ function SortableGroup({ group, lookup, allWaypoints, + newIds, onRename, onRemove, onToggleCollapsed, @@ -374,6 +382,7 @@ function SortableGroup({ name={w.name} isCustom={isCustom} isMoved={isMoved} + isNew={newIds.has(id)} onRemove={() => onRemoveWaypoint(id)} /> ); @@ -389,6 +398,7 @@ function SortableGroup({ onAddWaypoint(id)} onClose={() => setPickerOpen(false)} /> @@ -404,10 +414,11 @@ type SortableRowProps = { name: string; isCustom: boolean; isMoved: boolean; + isNew: boolean; onRemove: () => void; }; -function SortableRow({ id, name, isCustom, isMoved, onRemove }: SortableRowProps) { +function SortableRow({ id, name, isCustom, isMoved, isNew, onRemove }: SortableRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { @@ -436,6 +447,9 @@ function SortableRow({ id, name, isCustom, isMoved, onRemove }: SortableRowProps {isMoved && !isCustom && ( moved )} + {isNew && !isCustom && ( + new + )} - + + Shortcuts open each app's home page + + } + /> )} @@ -521,16 +535,125 @@ function Ready({ match, prefs, pendingId, copiedId, onOpen, onCopy }: ReadyProps ); })} -
- - + } /> +
+ ); +} + +// Footer renders the per-view leading content (URI button or shortcuts note) +// alongside the appearance toggle, and stacks a dismissable "new waypoints" +// banner above when the catalog has grown since the user last opened the +// popup. The Settings button moved to the header gear long ago — this is +// where the theme toggle lives now. +function PopupFooter({ prefs, leading }: { prefs: Prefs; leading: React.ReactNode }) { + const newWaypoints = useMemo(() => newBuiltinWaypoints(prefs), [prefs]); + + return ( +
+ {newWaypoints.length > 0 && ( + void openOptionsPage('waypoints')} + onDismiss={() => { + void markWaypointsKnown(newWaypoints.map(w => w.id)); + }} + /> + )} +
+ {leading} +
); } +function NewWaypointsBanner({ + waypoints, + onOpenSettings, + onDismiss, +}: { + waypoints: WaypointData[]; + onOpenSettings: () => void; + onDismiss: () => void; +}) { + const names = waypoints.map(w => w.name); + // Keep it short: list up to 3 by name, summarize the rest. + let summary: string; + if (names.length === 1) summary = `New waypoint: ${names[0]}`; + else if (names.length <= 3) summary = `New waypoints: ${names.join(', ')}`; + else summary = `${names.slice(0, 2).join(', ')} +${names.length - 2} more`; + + return ( +
+ + {summary} + + +
+ ); +} + +function ThemeToggle({ theme }: { theme: Prefs['theme'] }) { + const isDark = theme !== 'light'; + return ( + + ); +} + function CopyUriButton({ uri }: { uri: string }) { const [copied, setCopied] = useState(false); diff --git a/extension/entrypoints/popup/popup.css b/extension/entrypoints/popup/popup.css index 8fdc1ed..014234a 100644 --- a/extension/entrypoints/popup/popup.css +++ b/extension/entrypoints/popup/popup.css @@ -340,9 +340,8 @@ body { .popup-footer { display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; + flex-direction: column; + gap: 6px; padding: 10px 18px; border-top: 1px solid var(--border-subtle); background: var(--bg-secondary); @@ -352,6 +351,112 @@ body { z-index: 2; } +.popup-footer-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + min-width: 0; +} + +/* Inline update banner shown above the footer action row when the catalog + has grown since the user last opened the popup. Highlights new built-in + waypoints with an "Add" link to Settings → Waypoints and a dismiss X. */ +.popup-update-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 10px; + background: var(--glow-subtle); + border: 1px solid var(--text-accent); + color: var(--text-primary); + font-family: var(--font-serif); + font-size: var(--fs-xs); + line-height: 1.3; +} + +.popup-update-banner-icon { + display: inline-flex; + flex-shrink: 0; + color: var(--text-accent); +} + +.popup-update-banner-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popup-update-banner-action { + flex-shrink: 0; + padding: 2px 8px; + background: transparent; + border: 1px solid var(--text-accent); + color: var(--text-accent); + font-family: var(--font-sans); + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.popup-update-banner-action:hover { + background: var(--text-accent); + color: var(--bg-primary); +} + +.popup-update-banner-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: 0; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.2s ease, background 0.2s ease; +} + +.popup-update-banner-dismiss:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.popup-theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.popup-theme-toggle:hover { + background: var(--bg-tertiary); + border-color: var(--text-accent); + color: var(--text-accent); +} + +.popup-theme-toggle:active { + background: var(--bg-elevated); +} + +.popup-theme-toggle svg { + flex-shrink: 0; +} + .popup-uri { flex: 1; min-width: 0; diff --git a/extension/lib/__tests__/prefs-known.test.ts b/extension/lib/__tests__/prefs-known.test.ts new file mode 100644 index 0000000..3b84a9f --- /dev/null +++ b/extension/lib/__tests__/prefs-known.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { WAYPOINT_ORDER } from '@aturi/waypoints.data'; +import { DEFAULT_PREFS, addWaypointToGroup, type Prefs, type WaypointGroup } from '../prefs'; +import { newBuiltinWaypoints } from '../catalog'; + +// Internal helpers aren't exported, so we exercise the migration path the same +// way runtime code does: by reading prefs through `loadPrefs`. That goes +// through `chrome.storage`, which isn't available in vitest. Instead, we +// validate the user-visible behavior using the helpers that do the lifting. + +function makePrefs(overrides: Partial = {}): Prefs { + return { + ...DEFAULT_PREFS, + ...overrides, + }; +} + +describe('newBuiltinWaypoints', () => { + it('returns nothing when knownWaypointIds includes the full built-in list', () => { + const prefs = makePrefs({ knownWaypointIds: [...WAYPOINT_ORDER] }); + expect(newBuiltinWaypoints(prefs)).toEqual([]); + }); + + it('flags built-ins missing from knownWaypointIds in WAYPOINT_ORDER order', () => { + // Pretend "bluepy" and "deer" have never been seen. + const seen = WAYPOINT_ORDER.filter(id => id !== 'bluepy' && id !== 'deer'); + const prefs = makePrefs({ knownWaypointIds: seen }); + const flagged = newBuiltinWaypoints(prefs).map(w => w.id); + expect(flagged).toContain('bluepy'); + expect(flagged).toContain('deer'); + // Ordered by WAYPOINT_ORDER, so bluepy (earlier) comes before deer. + expect(flagged.indexOf('bluepy')).toBeLessThan(flagged.indexOf('deer')); + }); + + it('treats empty known list as nothing new — defensive against bad migrations', () => { + // This shouldn't happen in practice (mergePrefs seeds known), but if it + // does, blasting the user with every built-in as "new" would be terrible. + // The current implementation will *technically* return all built-ins, + // because the empty-array case is handled at the seeding layer, not here. + // Document that contract so the migration stays honest. + const prefs = makePrefs({ knownWaypointIds: [] }); + const flagged = newBuiltinWaypoints(prefs).map(w => w.id); + expect(flagged).toEqual([...WAYPOINT_ORDER]); + }); +}); + +describe('addWaypointToGroup', () => { + const group: WaypointGroup = { id: 'g1', name: 'Test', waypointIds: [] }; + + it('adds a built-in waypoint to the group and marks it known', () => { + const prefs = makePrefs({ + waypointGroups: [group], + knownWaypointIds: WAYPOINT_ORDER.filter(id => id !== 'bluepy'), + }); + + expect(newBuiltinWaypoints(prefs).map(w => w.id)).toEqual(['bluepy']); + + const next = addWaypointToGroup(prefs, 'g1', 'bluepy'); + expect(next.waypointGroups[0].waypointIds).toEqual(['bluepy']); + expect(next.knownWaypointIds).toContain('bluepy'); + expect(newBuiltinWaypoints(next)).toEqual([]); + }); + + it('does not pollute knownWaypointIds with custom waypoint ids', () => { + const prefs = makePrefs({ + waypointGroups: [group], + knownWaypointIds: [...WAYPOINT_ORDER], + }); + const next = addWaypointToGroup(prefs, 'g1', 'custom:abc'); + expect(next.knownWaypointIds).not.toContain('custom:abc'); + expect(next.waypointGroups[0].waypointIds).toEqual(['custom:abc']); + }); + + it('is a no-op for already-known waypoints (no duplicate entries)', () => { + const prefs = makePrefs({ + waypointGroups: [group], + knownWaypointIds: [...WAYPOINT_ORDER], + }); + const next = addWaypointToGroup(prefs, 'g1', 'bluepy'); + const count = next.knownWaypointIds.filter(id => id === 'bluepy').length; + expect(count).toBe(1); + }); +}); diff --git a/extension/lib/catalog.ts b/extension/lib/catalog.ts index 96e0724..7ddb0dd 100644 --- a/extension/lib/catalog.ts +++ b/extension/lib/catalog.ts @@ -269,6 +269,22 @@ export const DID_REQUIRED_WAYPOINTS = new Set([ 'popfeed', ]); +/** + * Built-in waypoints added since the user was last notified (i.e. not present + * in `prefs.knownWaypointIds`). Order matches `WAYPOINT_ORDER` so the popup + * banner reads them in a stable order. Custom waypoints are never returned. + */ +export function newBuiltinWaypoints(prefs: Prefs): WaypointData[] { + const known = new Set(prefs.knownWaypointIds ?? []); + const out: WaypointData[] = []; + for (const id of WAYPOINT_ORDER) { + if (known.has(id)) continue; + const w = WAYPOINT_DESTINATIONS_DATA[id]; + if (w) out.push(w); + } + return out; +} + export function requiresDid(waypointId: string, customWaypoints: CustomWaypoint[]): boolean { if (DID_REQUIRED_WAYPOINTS.has(waypointId)) return true; if (waypointId.startsWith('custom:')) { diff --git a/extension/lib/prefs.ts b/extension/lib/prefs.ts index b86e22a..c4eccd0 100644 --- a/extension/lib/prefs.ts +++ b/extension/lib/prefs.ts @@ -132,6 +132,17 @@ export type Prefs = { * `medium` is the new default and slightly larger than the previous baseline. */ fontSize: 'small' | 'medium' | 'large'; + /** + * Built-in waypoint ids the user has been notified about. Used to surface + * "New" badges in the popup and Settings → Waypoints when the extension + * adds new built-ins via update. Custom waypoints are not tracked here. + * + * Seeded the first time prefs are read: for existing installs we mark + * everything already in their groups (or previously hidden) as known, so + * only genuinely new built-ins get flagged. Brand-new installs are seeded + * with the full current built-in list — nothing is "new" on day one. + */ + knownWaypointIds: string[]; }; const CUSTOM_GROUP_ID = 'custom'; @@ -155,6 +166,7 @@ export const DEFAULT_PREFS: Prefs = { compactMode: false, theme: 'dark', fontSize: 'medium', + knownWaypointIds: [...WAYPOINT_ORDER], }; /** @@ -282,7 +294,12 @@ function getLocalArea(): StorageArea | null { function mergePrefs(partial: Partial | undefined): Prefs { if (!partial) { - return { ...DEFAULT_PREFS, waypointGroups: defaultWaypointGroups() }; + return { + ...DEFAULT_PREFS, + waypointGroups: defaultWaypointGroups(), + // Brand-new install: everything is "already known", nothing is new. + knownWaypointIds: [...WAYPOINT_ORDER], + }; } // Once `waypointGroups` exists in the saved payload, trust it (even if @@ -309,6 +326,7 @@ function mergePrefs(partial: Partial | undefined): Prefs { } const favoriteByFamily = migrateFavoriteByFamily(partial); + const knownWaypointIds = migrateKnownWaypointIds(partial, waypointGroups); return { ...DEFAULT_PREFS, @@ -322,9 +340,46 @@ function mergePrefs(partial: Partial | undefined): Prefs { waypointOrder: partial.waypointOrder ?? DEFAULT_PREFS.waypointOrder, categoryOverrides: partial.categoryOverrides ?? DEFAULT_PREFS.categoryOverrides, waypointGroups, + knownWaypointIds, }; } +/** + * Seed `knownWaypointIds` for existing installs that predate the field. + * Anything currently in a group or in the legacy `hiddenWaypoints` list is + * treated as already "seen"; the diff against the current built-in list is + * what shows up as new in the popup banner. + * + * Trust an explicit empty array from storage (the user dismissed and then + * we somehow ended up with no built-ins added since — that's fine), but + * fall back to seeding when the field is entirely absent. + */ +function migrateKnownWaypointIds( + partial: Partial, + waypointGroups: WaypointGroup[] +): string[] { + if (Array.isArray(partial.knownWaypointIds)) { + return partial.knownWaypointIds.filter(id => typeof id === 'string'); + } + + const seed = new Set(); + for (const group of waypointGroups) { + for (const id of group.waypointIds) { + if (!id.startsWith('custom:')) seed.add(id); + } + } + for (const id of partial.hiddenWaypoints ?? []) { + if (typeof id === 'string' && !id.startsWith('custom:')) seed.add(id); + } + if (seed.size === 0) { + // No signal in the stored prefs — treat as a fresh install and consider + // every built-in already known, so we don't blast the user with a "25 + // new waypoints" banner the first time they open the popup post-upgrade. + return [...WAYPOINT_ORDER]; + } + return Array.from(seed); +} + /** * Migrate the legacy single `favoriteWaypointId` into a `favoriteByFamily` * map. If the user already has a `favoriteByFamily` entry we trust it. When @@ -417,6 +472,7 @@ export async function savePrefs(update: Partial): Promise { waypointOrder: update.waypointOrder ?? current.waypointOrder, categoryOverrides: update.categoryOverrides ?? current.categoryOverrides, waypointGroups: update.waypointGroups ?? current.waypointGroups, + knownWaypointIds: update.knownWaypointIds ?? current.knownWaypointIds, }; const syncOk = await writeTo(getSyncArea(), next); @@ -678,8 +734,14 @@ export function addWaypointToGroup( groupId: string, waypointId: string ): Prefs { + const isBuiltin = !waypointId.startsWith('custom:'); + const knownWaypointIds = + isBuiltin && !prefs.knownWaypointIds.includes(waypointId) + ? [...prefs.knownWaypointIds, waypointId] + : prefs.knownWaypointIds; return { ...prefs, + knownWaypointIds, waypointGroups: prefs.waypointGroups.map(g => { if (g.id !== groupId) return g; if (g.waypointIds.includes(waypointId)) return g; @@ -688,6 +750,27 @@ export function addWaypointToGroup( }; } +/** + * Persist that the user has been notified about the given built-in waypoint + * ids — typically called when they dismiss the "new waypoints" banner in the + * popup, or after they add a new waypoint to a group via Settings. + */ +export async function markWaypointsKnown(ids: string[]): Promise { + if (ids.length === 0) return; + const current = await loadPrefs(); + const set = new Set(current.knownWaypointIds); + let changed = false; + for (const id of ids) { + if (id.startsWith('custom:')) continue; + if (!set.has(id)) { + set.add(id); + changed = true; + } + } + if (!changed) return; + await savePrefs({ knownWaypointIds: Array.from(set) }); +} + export function removeWaypointFromGroup( prefs: Prefs, groupId: string, diff --git a/src/utils/waypointIcons.tsx b/src/utils/waypointIcons.tsx index 2c53c2c..2045f2a 100644 --- a/src/utils/waypointIcons.tsx +++ b/src/utils/waypointIcons.tsx @@ -1,9 +1,28 @@ import { type ReactNode } from 'react'; -import { Wrench } from 'lucide-react'; import { AnisotaLogo } from '../components/AnisotaLogo'; export { AnisotaLogo }; +// Wrench mark — inlined from lucide-react. Kept local so this shared icon +// catalog stays free of third-party React deps; the extension's bundler can't +// resolve `lucide-react` from the parent project's source tree. +const WrenchSVG = () => ( + +); + export const BlueskySVG = () => ( @@ -191,7 +210,7 @@ export const WAYPOINT_ICONS: Record = { pdsls: , anisotaReader: , anisotaExplorer: , - atptools: , + atptools: , witchsky: , catsky: , deer: , -- 2.51.2