diff --git a/apps/extension/package.json b/apps/extension/package.json index 5c74033..34ef756 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@dockmark/extension", - "version": "1.5.2", + "version": "1.5.3", "private": true, "type": "module", "scripts": { diff --git a/apps/extension/public/newtab-parity.js b/apps/extension/public/newtab-parity.js new file mode 100644 index 0000000..a30d7e2 --- /dev/null +++ b/apps/extension/public/newtab-parity.js @@ -0,0 +1,139 @@ +(() => { + const SOURCE_WEIGHT = { + tab: 500, + workspace: 400, + session: 350, + collection: 325, + bookmark: 300, + navigation: 200, + search: 100, + }; + const KIND_LABEL = { + tab: "Open tab", + workspace: "Workspace", + session: "Session", + collection: "Collection", + bookmark: "Bookmark", + navigation: "Navigation", + search: "Search", + "search-shortcut": "Bang", + }; + const originalBuildResults = buildResults; + const originalRenderSearchResults = renderSearchResults; + + function parityTextScore(query, primary, secondary = [], base = 0) { + const needle = String(query || "").trim().toLocaleLowerCase(); + if (!needle) return base; + const primaryText = String(primary || "").toLocaleLowerCase(); + const secondaryText = secondary.map((value) => String(value || "").toLocaleLowerCase()).join("\n"); + const terms = needle.split(/\s+/).filter(Boolean); + if (!terms.every((term) => primaryText.includes(term) || secondaryText.includes(term))) return -1; + + let score = base + Math.min(18, Math.max(0, terms.length - 1) * 3); + if (primaryText === needle) score += 90; + else if (primaryText.startsWith(needle)) score += 60; + else if (primaryText.includes(needle)) score += 35; + else if (secondaryText.includes(needle)) score += 15; + else score += 8; + return score; + } + + function sourceForKind(kind) { + return kind === "search-shortcut" ? "search" : kind; + } + + function canonicalScore(query, result) { + const source = sourceForKind(result?.kind); + const weight = SOURCE_WEIGHT[source] ?? 0; + const match = parityTextScore(query, result?.title, [result?.subtitle], 0); + return weight + Math.max(0, match); + } + + function workspaceResult(query, workspace) { + const items = asArray(workspace?.items).filter((item) => isHttpUrl(item?.url)); + const subtitle = `${items.length} tab${items.length === 1 ? "" : "s"}`; + const secondary = [workspace?.description, subtitle, ...items.flatMap((item) => [item?.title, item?.url])]; + const match = parityTextScore(query, workspace?.name, secondary, 30); + if (match < 0) return null; + return { + kind: "workspace", + title: workspace?.name || "Workspace", + subtitle, + workspace, + score: SOURCE_WEIGHT.workspace + match, + }; + } + + function sessionResult(query, session, index) { + const items = asArray(session?.items).filter((item) => isHttpUrl(item?.url)); + const sourceDevice = typeof session?.sourceDevice === "string" ? session.sourceDevice.trim() : ""; + const secondary = [sourceDevice, ...items.flatMap((item) => [item?.title, item?.url])]; + const match = parityTextScore(query, session?.name, secondary, Math.max(0, 30 - index)); + if (match < 0) return null; + + const terms = String(query || "").trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const matchedItem = items.find((item) => { + const text = `${item?.title || ""}\n${item?.url || ""}`.toLocaleLowerCase(); + return terms.length > 0 && terms.every((term) => text.includes(term)); + }) || null; + const source = sourceDevice ? ` · ${sourceDevice}` : ""; + const itemMatch = matchedItem ? ` · ${matchedItem.title || displayHost(matchedItem.url)} · ${matchedItem.url}` : ""; + return { + kind: "session", + title: session?.name || "Session", + subtitle: `${items.length} tab${items.length === 1 ? "" : "s"}${source}${itemMatch}`, + session, + score: SOURCE_WEIGHT.session + match - index * 0.001, + }; + } + + buildResults = function parityBuildResults(rawQuery) { + const query = rawQuery.trim(); + if (!query) return []; + + const existing = originalBuildResults(rawQuery); + if (query.startsWith("@") || searchEngineCommand(query) || (existing.length && existing.every((result) => result.kind === "search-shortcut"))) { + return existing; + } + + const deduped = new Map(); + for (const result of existing) { + if (result.kind === "workspace" || result.kind === "session") continue; + deduped.set(`${result.kind}:${result.url || result.collection?.id || result.title}`, { + ...result, + score: canonicalScore(query, result), + }); + } + + for (const workspace of asArray(snapshot.workspaces)) { + const result = workspaceResult(query, workspace); + if (result) deduped.set(`workspace:${workspace?.id || result.title}`, result); + } + asArray(snapshot.sessions).forEach((session, index) => { + const result = sessionResult(query, session, index); + if (result) deduped.set(`session:${session?.id || result.title}`, result); + }); + + return [...deduped.values()] + .sort((left, right) => right.score - left.score || String(left.title || "").localeCompare(String(right.title || ""))) + .slice(0, 10); + }; + + renderSearchResults = function parityRenderSearchResults() { + originalRenderSearchResults(); + const labels = elements.results.querySelectorAll(".result-kind"); + labels.forEach((label, index) => { + const kind = visibleResults[index]?.kind; + label.textContent = KIND_LABEL[kind] || kind || ""; + }); + }; + + elements.search.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || elements.search.value) return; + event.preventDefault(); + event.stopImmediatePropagation(); + elements.search.blur(); + }, true); + + queueMicrotask(() => renderSearchResults()); +})(); diff --git a/apps/extension/public/newtab.html b/apps/extension/public/newtab.html index 4baf6ca..ccd31d5 100644 --- a/apps/extension/public/newtab.html +++ b/apps/extension/public/newtab.html @@ -66,5 +66,6 @@

Connect and pair Dockmark once to seed this launcher.

+ diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 08ea44c..1261d80 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ modules: ["@wxt-dev/module-react"], manifest: ({ browser }) => ({ name: "Dockmark", - version: "1.5.2", + version: "1.5.3", description: "Browser bridge for Dockmark tabs, reviewed Capture Inbox, sessions, Smart Collection-aware local-first search, reviewed bookmark sync, local health checks and device pairing.", icons: { 16: "icons/dockmark-16.png", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fed3c56..e4fb087 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { buildSearchUrl, + launcherTextScore, matchingBangEngines, parseBangQuery, rankCommands, @@ -27,7 +28,7 @@ import { listSessions, listWorkspaces, } from "./api"; -import { bookmarkMatchesQuery, textMatchesQuery } from "./bookmark-search"; +import { bookmarkMatchesQuery } from "./bookmark-search"; import { listInbox } from "./inbox-api"; import { matchesSmartCollection, smartCollectionFilterLabels } from "./smart-collection-match"; import { listSmartCollections, type SmartCollection } from "./smart-collections-api"; @@ -43,6 +44,7 @@ const sourceLabel: Record = { tab: "Open tab", workspace: "Workspace", session: "Session", + collection: "Collection", bookmark: "Bookmark", navigation: "Navigation", search: "Search", @@ -158,13 +160,22 @@ export function App() { }, [refreshBridge]); useEffect(() => { + const focusLauncher = () => { + setView("launcher"); + void refreshBridge(); + requestAnimationFrame(() => commandInputRef.current?.focus()); + }; const onGlobalKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); - setView("launcher"); - void refreshBridge(); - requestAnimationFrame(() => commandInputRef.current?.focus()); + focusLauncher(); + return; } + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || (target instanceof HTMLElement && target.isContentEditable)) return; + event.preventDefault(); + focusLauncher(); }; window.addEventListener("keydown", onGlobalKeyDown); return () => window.removeEventListener("keydown", onGlobalKeyDown); @@ -182,7 +193,7 @@ export function App() { const results = useMemo(() => { const trimmed = query.trim(); - const normalized = trimmed.toLowerCase(); + const normalized = trimmed.toLocaleLowerCase(); const bang = parseBangQuery(trimmed, searchEngines); if (bang) { @@ -209,40 +220,59 @@ export function App() { } const tabCandidates = normalized ? openTabs : openTabs.slice(0, 4); - const tabResults: CommandResult[] = tabCandidates.map((tab) => ({ - id: `tab:${tab.id}`, - source: "tab", - title: tab.title, - subtitle: `${hostLabel(tab.url)}${tab.pinned ? " · Pinned" : ""}${tab.active ? " · Active" : ""}`, - url: tab.url, - score: 40 + (tab.pinned ? 4 : 0) + (tab.active ? 2 : 0), - })); - const workspaceResults: CommandResult[] = workspaces.map((workspace) => ({ - id: workspace.id, - source: "workspace", - title: workspace.name, - subtitle: `Open workspace · ${workspace.items.length} tabs`, - score: 30, - })); - const sessionResults: CommandResult[] = sessions.map((session, index) => ({ - id: session.id, - source: "session", - title: session.name, - subtitle: `Restore session · ${session.items.length} tabs${session.sourceDevice ? ` · ${session.sourceDevice}` : ""}`, - score: Math.max(0, 30 - index), - })); + const tabResults: CommandResult[] = tabCandidates.flatMap((tab) => { + const subtitle = `${hostLabel(tab.url)}${tab.pinned ? " · Pinned" : ""}${tab.active ? " · Active" : ""}`; + const score = launcherTextScore(trimmed, tab.title, [tab.url, subtitle], 40 + (tab.pinned ? 4 : 0) + (tab.active ? 2 : 0)); + if (normalized && score < 0) return []; + return [{ + id: `tab:${tab.id}`, + source: "tab" as const, + title: tab.title, + subtitle, + url: tab.url, + score, + }]; + }); + const workspaceResults: CommandResult[] = workspaces.flatMap((workspace) => { + const subtitle = `Open workspace · ${workspace.items.length} tabs`; + const itemText = workspace.items.flatMap((item) => [item.title, item.url]); + const score = launcherTextScore(trimmed, workspace.name, [workspace.description ?? "", subtitle, ...itemText], 30); + if (normalized && score < 0) return []; + return [{ + id: workspace.id, + source: "workspace" as const, + title: workspace.name, + subtitle, + score, + }]; + }); + const sessionResults: CommandResult[] = sessions.flatMap((session, index) => { + const subtitle = `Restore session · ${session.items.length} tabs${session.sourceDevice ? ` · ${session.sourceDevice}` : ""}`; + const itemText = session.items.flatMap((item) => [item.title, item.url]); + const score = launcherTextScore(trimmed, session.name, [session.sourceDevice ?? "", subtitle, ...itemText], Math.max(0, 30 - index)); + if (normalized && score < 0) return []; + return [{ + id: session.id, + source: "session" as const, + title: session.name, + subtitle, + score, + }]; + }); const collectionResults: CommandResult[] = smartCollections.flatMap((collection) => { const labels = smartCollectionFilterLabels(collection.filters, categoryById); - if (normalized && !textMatchesQuery(trimmed, [collection.name, ...labels])) return []; const count = bookmarks.filter((bookmark) => matchesSmartCollection(bookmark, collection.filters, bookmarkTags[bookmark.id] ?? [], inboxIds), ).length; + const subtitle = `${count} bookmark${count === 1 ? "" : "s"}${labels.length ? ` · ${labels.slice(0, 4).join(" · ")}` : " · All bookmarks"}`; + const score = launcherTextScore(trimmed, collection.name, [subtitle, ...labels], 30); + if (normalized && score < 0) return []; return [{ id: collectionResultId(collection.id), - source: "navigation" as const, + source: "collection" as const, title: collection.name, - subtitle: `${count} bookmark${count === 1 ? "" : "s"}${labels.length ? ` · ${labels.slice(0, 4).join(" · ")}` : " · All bookmarks"}`, - score: 95, + subtitle, + score, }]; }); const bookmarkResults: CommandResult[] = bookmarks.flatMap((bookmark) => { @@ -253,37 +283,36 @@ export function App() { const subtitleParts = [hostLabel(bookmark.url)]; if (category) subtitleParts.push(category); if (tagTokens.length) subtitleParts.push(tagTokens.slice(0, 3).join(" ")); + const score = launcherTextScore(trimmed, bookmark.title, [bookmark.url, bookmark.description ?? "", category, ...tags, ...tagTokens], 30); + if (normalized && score < 0) return []; return [{ id: bookmark.id, source: "bookmark" as const, title: bookmark.title, subtitle: subtitleParts.join(" · "), url: bookmark.url, - score: 30, + score, }]; }); - const navigationResults: CommandResult[] = [{ + const navigationTitle = bridgeConnected ? "Browser extension" : "Install browser extension"; + const navigationSubtitle = bridgeConnected ? "Diagnostics, version and capabilities" : "Enable open tabs, Session restore and native bookmark import"; + const navigationScore = launcherTextScore(trimmed, navigationTitle, [navigationSubtitle], 20); + const navigationResults: CommandResult[] = normalized && navigationScore < 0 ? [] : [{ id: "navigation:extension", source: "navigation", - title: bridgeConnected ? "Browser extension" : "Install browser extension", - subtitle: bridgeConnected ? "Diagnostics, version and capabilities" : "Enable open tabs, Session restore and native bookmark import", - score: 20, + title: navigationTitle, + subtitle: navigationSubtitle, + score: navigationScore, }]; - const localCandidates = [ + const localResults = rankCommands([ ...tabResults, ...workspaceResults, ...sessionResults, ...collectionResults, ...bookmarkResults, ...navigationResults, - ]; - const filtered = normalized - ? localCandidates.filter((item) => - item.source === "bookmark" || item.id.startsWith("navigation:collection:") || `${item.title} ${item.subtitle ?? ""}`.toLowerCase().includes(normalized), - ) - : localCandidates; - const localResults = rankCommands(filtered).slice(0, trimmed && defaultEngine ? 7 : 8); + ]).slice(0, trimmed && defaultEngine ? 7 : 8); if (trimmed && defaultEngine) { localResults.push({ @@ -336,7 +365,7 @@ export function App() { return; } - if (item.id.startsWith("navigation:collection:")) { + if (item.source === "collection" && item.id.startsWith("navigation:collection:")) { const collectionId = item.id.slice("navigation:collection:".length); const path = `/app/collections?collection=${encodeURIComponent(collectionId)}`; if (forceNewTab) { @@ -430,11 +459,19 @@ export function App() { if (results.length) setSelectedIndex((index) => (index - 1 + results.length) % results.length); return; } + if (event.key === "Tab") { + const selected = results[selectedIndex]; + if (selected?.id.startsWith("search-shortcut:")) { + event.preventDefault(); + void executeResult(selected); + } + return; + } if (event.key === "Enter") { const selected = results[selectedIndex]; if (selected) { event.preventDefault(); - void executeResult(selected, event.shiftKey); + void executeResult(selected, event.shiftKey || event.metaKey || event.ctrlKey); } return; } @@ -510,7 +547,7 @@ export function App() { onChange={(event) => setQuery(event.target.value)} onFocus={() => void refreshBridge()} onKeyDown={onCommandKeyDown} - placeholder="Search tabs, Smart Collections, bookmarks, descriptions or #tags; type !g, !gh, !ddg…" + placeholder="Search tabs, Sessions, Workspaces, Collections, bookmarks or #tags; type !g, !gh, !ddg…" aria-label="Search Dockmark" aria-controls="dockmark-command-results" aria-activedescendant={results[selectedIndex] ? `command-result-${selectedIndex}` : undefined} @@ -535,7 +572,7 @@ export function App() { ) : (
{results.map((item, index) => { - const isCollection = item.id.startsWith("navigation:collection:"); + const isCollection = item.source === "collection"; return ( ); })} @@ -560,8 +597,8 @@ export function App() {
)}
- ↑↓ select Enter run - Shift+Enter new tab Esc clear + ↑↓ select Enter run Tab accept bang + Shift/⌘/Ctrl+Enter new tab / focus Esc clear
diff --git a/apps/web/src/extension-distribution.ts b/apps/web/src/extension-distribution.ts index a6cad1f..f3e02e1 100644 --- a/apps/web/src/extension-distribution.ts +++ b/apps/web/src/extension-distribution.ts @@ -1,4 +1,4 @@ -export const EXTENSION_RELEASE_VERSION = "1.5.2"; +export const EXTENSION_RELEASE_VERSION = "1.5.3"; // Fill these when browser-store listings are public. The UI will promote the // store action while keeping manual downloads visible. diff --git a/package.json b/package.json index c6c5846..7270842 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "deploy:dry-run": "npm run prepare:cloudflare-deploy && wrangler deploy --dry-run --outdir .wrangler/dry-run", "deploy": "npm run build:web && npm run deploy:built", "smoke:chromium": "node scripts/chromium-extension-smoke.mjs", - "smoke:chromium:newtab": "node scripts/chromium-newtab-smoke.mjs && node scripts/chromium-newtab-ux-smoke.mjs" + "smoke:chromium:newtab": "node scripts/chromium-newtab-smoke.mjs && node scripts/chromium-newtab-ux-smoke.mjs && node scripts/chromium-newtab-parity-smoke.mjs" }, "devDependencies": { "typescript": "^7.0.2", diff --git a/packages/core/src/command-rank.ts b/packages/core/src/command-rank.ts index 32f0f95..fbff1db 100644 --- a/packages/core/src/command-rank.ts +++ b/packages/core/src/command-rank.ts @@ -1,18 +1,56 @@ import type { CommandResult, CommandSource } from "./models"; -const SOURCE_WEIGHT: Record = { +export const LAUNCHER_SOURCE_WEIGHT: Record = { tab: 500, workspace: 400, session: 350, + collection: 325, bookmark: 300, navigation: 200, search: 100, }; +export const LAUNCHER_SOURCE_LABEL: Record = { + tab: "Open tab", + workspace: "Workspace", + session: "Session", + collection: "Collection", + bookmark: "Bookmark", + navigation: "Navigation", + search: "Search", +}; + +function normalize(value: string | null | undefined) { + return String(value ?? "").toLocaleLowerCase(); +} + +export function launcherTextScore( + query: string, + primary: string | null | undefined, + secondary: Array = [], + base = 0, +): number { + const needle = normalize(query).trim(); + if (!needle) return base; + + const primaryText = normalize(primary); + const secondaryText = secondary.map(normalize).join("\n"); + const terms = needle.split(/\s+/).filter(Boolean); + if (!terms.every((term) => primaryText.includes(term) || secondaryText.includes(term))) return -1; + + let score = base + Math.min(18, Math.max(0, terms.length - 1) * 3); + if (primaryText === needle) score += 90; + else if (primaryText.startsWith(needle)) score += 60; + else if (primaryText.includes(needle)) score += 35; + else if (secondaryText.includes(needle)) score += 15; + else score += 8; + return score; +} + export function rankCommands(results: CommandResult[]): CommandResult[] { return [...results].sort((a, b) => { - const aScore = SOURCE_WEIGHT[a.source] + a.score; - const bScore = SOURCE_WEIGHT[b.source] + b.score; + const aScore = LAUNCHER_SOURCE_WEIGHT[a.source] + a.score; + const bScore = LAUNCHER_SOURCE_WEIGHT[b.source] + b.score; return bScore - aScore || a.title.localeCompare(b.title); }); } diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 9723782..fa16b96 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -218,7 +218,7 @@ export interface UpdateSearchEngineInput { position?: number; } -export type CommandSource = "tab" | "workspace" | "session" | "bookmark" | "navigation" | "search"; +export type CommandSource = "tab" | "workspace" | "session" | "collection" | "bookmark" | "navigation" | "search"; export interface CommandResult { id: string; diff --git a/scripts/chromium-newtab-parity-smoke.mjs b/scripts/chromium-newtab-parity-smoke.mjs new file mode 100644 index 0000000..821343a --- /dev/null +++ b/scripts/chromium-newtab-parity-smoke.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const extensionPath = path.join(root, "apps/extension/.output/chrome-mv3-newtab"); +const userDataDir = await mkdtemp(path.join(tmpdir(), "dockmark-newtab-parity-")); + +let context; +try { + context = await chromium.launchPersistentContext(userDataDir, { + channel: "chromium", + headless: true, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + + let [serviceWorker] = context.serviceWorkers(); + if (!serviceWorker) serviceWorker = await context.waitForEvent("serviceworker"); + const extensionId = new URL(serviceWorker.url()).host; + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/newtab.html`); + await page.waitForLoadState("domcontentloaded"); + + await page.evaluate(() => { + const now = new Date().toISOString(); + settings = normalizeSettings({ + version: 1, + conflictPreference: "ask", + newTab: { + defaultSearchEngineId: "engine-parity", + showOpenTabs: true, + bookmarkLimit: 12, + workspaceLimit: 8, + autoRefresh: false, + }, + updatedAt: now, + }); + snapshot = { + version: 1, + origin: "https://parity.dockmark.invalid", + syncedAt: now, + categories: [], + inboxBookmarkIds: [], + bookmarks: [{ + id: "bookmark-parity", + title: "Shared Match Bookmark", + url: "https://bookmark.example/shared-match", + description: "Parity fixture", + tags: [], + healthPolicy: "normal", + healthStatus: "unknown", + position: 0, + createdAt: now, + updatedAt: now, + }], + workspaces: [{ + id: "workspace-parity", + name: "Shared Match Workspace", + description: "Parity fixture", + position: 0, + createdAt: now, + updatedAt: now, + items: [{ + id: "workspace-item-parity", + workspaceId: "workspace-parity", + title: "workspace-only-item", + url: "https://workspace.example/only-item", + openMode: "reuse", + healthPolicy: "normal", + position: 0, + createdAt: now, + updatedAt: now, + }], + }], + sessions: [{ + id: "session-parity", + name: "Shared Match Session", + sourceDevice: "Parity browser", + createdAt: now, + updatedAt: now, + items: [{ + id: "session-item-parity", + sessionId: "session-parity", + title: "Session parity tab", + url: "https://session.example/shared-match", + pinned: false, + position: 0, + }], + }], + smartCollections: [{ + id: "collection-parity", + name: "Shared Match Collection", + filters: {}, + position: 0, + createdAt: now, + updatedAt: now, + }], + searchEngines: [{ + id: "engine-parity", + name: "Google", + keyword: "g", + searchUrl: "https://www.google.com/search?q=%s", + isDefault: true, + position: 0, + }], + }; + openTabs = [{ + id: 999, + windowId: 1, + title: "Shared Match Tab", + url: "https://tab.example/shared-match", + active: false, + }]; + activeResult = 0; + elements.search.value = "Shared Match"; + renderSearchResults(); + }); + + const labels = (await page.locator("#command-results .result-kind").allInnerTexts()) + .map((value) => value.toLocaleLowerCase()); + assert.deepEqual(labels, ["open tab", "workspace", "session", "collection", "bookmark", "search"]); + + const titles = await page.locator("#command-results .result-row strong").allInnerTexts(); + assert.deepEqual(titles.slice(0, 5), [ + "Shared Match Tab", + "Shared Match Workspace", + "Shared Match Session", + "Shared Match Collection", + "Shared Match Bookmark", + ]); + + const search = page.locator("#search"); + await search.fill("workspace-only-item"); + const workspaceRow = page.locator("#command-results .result-row").filter({ hasText: "Shared Match Workspace" }).first(); + await workspaceRow.waitFor(); + assert.match(await workspaceRow.innerText(), /Workspace/i); + + await search.fill(""); + await search.focus(); + await search.press("Escape"); + assert.equal(await search.evaluate((element) => document.activeElement === element), false, "Escape on an empty Launcher should blur it on both Web and New Tab."); + + console.log("✓ New Tab canonical ordering is Open tab > Workspace > Session > Collection > Bookmark > Search"); + console.log("✓ New Tab exposes the same human-readable result type labels as Web Launcher"); + console.log("✓ Workspace item title/URL content participates in New Tab Launcher search"); + console.log("✓ Escape clears first and blurs the empty Launcher consistently"); +} finally { + await context?.close(); + await rm(userDataDir, { recursive: true, force: true }); +} diff --git a/scripts/chromium-newtab-ux-smoke.mjs b/scripts/chromium-newtab-ux-smoke.mjs index bf2ca27..d2bf5f5 100644 --- a/scripts/chromium-newtab-ux-smoke.mjs +++ b/scripts/chromium-newtab-ux-smoke.mjs @@ -219,7 +219,7 @@ try { await page.getByText("GitHub", { exact: true }).waitFor(); const renderedKinds = (await page.locator("#command-results .result-kind").allInnerTexts()) .map((value) => value.toLocaleLowerCase()); - assert.deepEqual(renderedKinds, ["search-shortcut", "search-shortcut"]); + assert.deepEqual(renderedKinds, ["bang", "bang"], "Search-engine shortcuts should use the shared Bang result label."); await search.fill("!gh"); await page.getByText("GitHub", { exact: true }).waitFor(); diff --git a/scripts/package-newtab-extension.mjs b/scripts/package-newtab-extension.mjs index dce9bd2..5de387f 100644 --- a/scripts/package-newtab-extension.mjs +++ b/scripts/package-newtab-extension.mjs @@ -25,7 +25,7 @@ manifest.description = "Dockmark browser bridge with a local-first, offline-capa manifest.chrome_url_overrides = { newtab: "newtab.html" }; await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); -for (const required of ["newtab.html", "newtab.js", "newtab-search.js", "newtab-ux.js", "newtab-auth.js", "newtab.css", "newtab-icon.svg"]) { +for (const required of ["newtab.html", "newtab.js", "newtab-search.js", "newtab-ux.js", "newtab-parity.js", "newtab-auth.js", "newtab.css", "newtab-icon.svg"]) { await readFile(path.join(variantDir, required)); }