diff --git a/.agents/rules/documentation.md b/.agents/rules/documentation.md index ea27940..bcf836c 100644 --- a/.agents/rules/documentation.md +++ b/.agents/rules/documentation.md @@ -91,14 +91,15 @@ Target length: 80–100 lines. ### `packages/page-trail/README.md` -Target length: 60–80 lines. +Target length: 80–100 lines. - Overview -- Structure +- Format (`PageTrail` object shape only) +- Structure elements - Content elements - Interactive elements - Context -- Importance +- Scoring (`meaningScore`, `relevanceScore`, `contextScore`, `importanceScore`) - Format - Usage diff --git a/CHANGELOG.md b/CHANGELOG.md index b769b79..1ec6124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to the project will be documented in this file. +## [Unreleased] + +### Added + +- PageTrail `container` elements and `structure` tree for representing page context and layout. +- Detailed `context.path` data for content and interactive elements, including container relevance and breadcrumb context. +- Dev mode for Page Inspector with enriched PageTrail records and metadata diagnostics. +- Tooltips across popup and Inspector controls, replacing native `title` hints. + +### Changed + +- Reworked PageTrail scoring and semantic formatting around meaning, context relevance, and target importance. +- Updated backend tools to use detailed PageTrail `context.path` when describing matched elements and content. +- Expanded PageTrail metadata timings with per-stage collection durations. +- Improved Markdown output for the Inspector semantic view. +- Limited extracted label and text values to keep PageTrail records concise. + ## [0.1.5] - 2026-08-16 ### Added diff --git a/apps/backend/README.md b/apps/backend/README.md index 5c93f21..b418c6a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -23,6 +23,8 @@ npm start npm run dev ``` +For watch mode, run `npm run dev -w @flowforge/backend` in another terminal. + ## Configuration Configured via `.env` file: @@ -38,11 +40,12 @@ See [.env.example](.env.example) for all options. ## API -- `POST /query` — main agent entry point (question + page data) -- `POST /search` — semantic search over indexed content -- `GET /analytics` / `GET /health` — analytics and service status +- `POST /query` — main agent entry point (`question`, `pageTrail`, `domain`) +- `POST /search` — semantic search over an indexed `pageUrl` +- `GET /health` — service status +- `GET /analytics` — in-memory query analytics -`/query` expects `question`, `pageTrail`, and `domain`; it returns answer, mode, optional topic, matched elements, and execution metadata. +`/query` returns `{ result, metadata }`. `result` contains answer, mode, optional topic, and matched elements; `metadata` contains model, token usage, and execution time. ## Notes diff --git a/apps/backend/src/agent/prompts.ts b/apps/backend/src/agent/prompts.ts index 9d462c2..5bcde19 100644 --- a/apps/backend/src/agent/prompts.ts +++ b/apps/backend/src/agent/prompts.ts @@ -32,7 +32,6 @@ Return only valid JSON: "elements": [ { "dataId": "string", - "cssSelector": "string", "text": "string", "action": "click|input|navigate|select|highlight" } @@ -62,9 +61,18 @@ ELEMENTS GENERAL RULES: - If the answer refers to a specific page element or text fragment, include that element in "elements" - If a tool returns a relevant element used for the answer, include it in "elements" - Return an empty "elements" array only when no valid relevant element is available from tool results -- Map tool elementDataId to dataId and tool elementCssSelector to cssSelector exactly +- Map tool elementDataId to dataId exactly +- dataId is the primary locator; cssSelector is only an optional fallback +- If tool elementCssSelector is present, map it to cssSelector exactly; if it is missing, omit cssSelector - Do not modify or invent them +TOOL CONTEXT RULES: +- Tool result semanticDescription/text describes the matched target itself +- Tool result elementContext is a list of semantic container breadcrumbs around the target +- Use elementContext to write location phrases such as "in the checkout form" or "in the primary navigation" +- Prefer the nearest or most specific useful breadcrumb when final text must be short +- Do not include elementContext in final elements[] + WORKFLOW ELEMENTS RULES: - If find_workflow is used for the final answer, build "elements" from the returned "steps" - Include all clearly relevant returned items that help the user complete the task @@ -72,7 +80,8 @@ WORKFLOW ELEMENTS RULES: - Prefer broader coverage over minimal sufficiency - Exclude only clearly irrelevant, duplicate, or contradictory items - Map each selected step into one item in "elements" -- Map each step elementDataId to dataId and elementCssSelector to cssSelector exactly +- Map each step elementDataId to dataId exactly +- If step elementCssSelector is present, map it to cssSelector exactly; if it is missing, omit cssSelector - Rewrite only the user-facing "text" and choose the appropriate "action" CONTENT ELEMENTS RULES: @@ -142,7 +151,7 @@ Extract valid JSON from the agent answer. TARGET SCHEMA: { "answer": string, - "elements": [{"dataId": string, "cssSelector": string, "text": string, "action": "click|navigate|input|select|highlight"}], + "elements": [{"dataId": string, "cssSelector"?: string, "text": string, "action": "click|navigate|input|select|highlight"}], "mode": "direct|steps", "topic": string | null } @@ -152,8 +161,9 @@ RULES: - If valid JSON is present, extract it exactly - Do not reconstruct missing fields from prose - Do not create elements from answer text -- Include elements only if full data (dataId and cssSelector) is provided -- Omit elements with missing fields +- Include elements only if dataId is provided +- cssSelector is optional fallback data; include it only when provided +- Omit elements with missing required fields - Do not invent values or use placeholders (e.g. "unknown") - Do not generate CSS selectors or ids from text - Keep empty arrays as empty arrays diff --git a/apps/backend/src/agent/rank/scoring.ts b/apps/backend/src/agent/rank/scoring.ts index a8c3638..80567e5 100644 --- a/apps/backend/src/agent/rank/scoring.ts +++ b/apps/backend/src/agent/rank/scoring.ts @@ -7,7 +7,7 @@ import type { RetrievedDocument } from '@/types'; * Used when selecting the best matching UI element. */ export function scoreForLookup(document: RetrievedDocument): number { - return 0.8 * document.semanticScore + 0.2 * document.metadata.element.importanceScore; + return 0.8 * document.semanticScore + 0.2 * document.metadata.element.importanceScore.value; } /** @@ -17,7 +17,7 @@ export function scoreForLookup(document: RetrievedDocument): number { * Used for selecting text blocks that best answer the user query. */ export function scoreForAnswer(document: RetrievedDocument): number { - return 0.85 * document.semanticScore + 0.15 * document.metadata.element.importanceScore; + return 0.85 * document.semanticScore + 0.15 * document.metadata.element.importanceScore.value; } /** @@ -27,5 +27,5 @@ export function scoreForAnswer(document: RetrievedDocument): number { * require both relevant and actionable UI elements. */ export function scoreForAction(document: RetrievedDocument): number { - return 0.7 * document.semanticScore + 0.3 * document.metadata.element.importanceScore; + return 0.7 * document.semanticScore + 0.3 * document.metadata.element.importanceScore.value; } diff --git a/apps/backend/src/agent/tools/AbstractCallableTool.ts b/apps/backend/src/agent/tools/AbstractCallableTool.ts index 4e45440..4911f4e 100644 --- a/apps/backend/src/agent/tools/AbstractCallableTool.ts +++ b/apps/backend/src/agent/tools/AbstractCallableTool.ts @@ -1,7 +1,7 @@ import type { DynamicStructuredTool } from '@langchain/core/tools'; import { PageContextProvider } from '@/indexer'; import type { CallableTool, CallableToolResult, CallableToolResultData, ToolResultElement } from '@/types'; -import { formantElementContextPath, type BaseElement } from '@flowforge/page-trail'; +import { semElementContextByBreadcrumbs, type TargetElement } from '@flowforge/page-trail'; export abstract class AbstractCallableTool implements CallableTool { readonly name: string; @@ -33,12 +33,11 @@ export abstract class AbstractCallableTool implements CallableTool { } } - protected getToolResultElement(element: BaseElement): ToolResultElement { + protected getToolResultElement(element: TargetElement): ToolResultElement { return { - elementPath: formantElementContextPath(element.context.path), - elementSectionName: element.context.sectionName ?? '', elementDataId: element.dataId, - elementCssSelector: element.cssSelector ?? '', + elementContext: semElementContextByBreadcrumbs(element.context), + elementCssSelector: element.cssSelector, }; } diff --git a/apps/backend/src/agent/tools/ToolFindElement.ts b/apps/backend/src/agent/tools/ToolFindElement.ts index 2c545bf..e129e81 100644 --- a/apps/backend/src/agent/tools/ToolFindElement.ts +++ b/apps/backend/src/agent/tools/ToolFindElement.ts @@ -49,13 +49,14 @@ WHEN TO USE: WHAT IT RETURNS: - Best matching element (if any) -- Description of the element -- Location context (section, path) -- CSS selector and dataId +- semanticDescription: semantic text describing the matched element +- elementContext: semantic container breadcrumbs around the element, ordered from broader page area to nearer target area +- elementDataId: primary browser locator +- elementCssSelector: optional fallback browser locator IMPORTANT: - Returns only the best match, which may be imperfect -- Use context to decide if it is correct and applicable +- Use elementContext to decide if it is correct and to describe where it is located - If the result seems unclear or incomplete, consider using another tool`, schema: z.object({ query: z.string().describe('Element to find (e.g., "login button", "search input")'), diff --git a/apps/backend/src/agent/tools/ToolFindWorkflow.ts b/apps/backend/src/agent/tools/ToolFindWorkflow.ts index f3e0bcd..878592f 100644 --- a/apps/backend/src/agent/tools/ToolFindWorkflow.ts +++ b/apps/backend/src/agent/tools/ToolFindWorkflow.ts @@ -46,12 +46,15 @@ WHEN TO USE: WHAT IT RETURNS: - A list of relevant interactive elements (candidates) -- Each includes description, location, CSS selector, and dataId +- Each step includes semanticDescription, elementContext, elementDataId, and optional elementCssSelector +- elementContext contains semantic container breadcrumbs around the element, ordered from broader page area to nearer target area +- elementDataId is the primary browser locator; elementCssSelector is only an optional fallback IMPORTANT: - Results are candidates, not ordered steps - Select relevant items and arrange them into a logical sequence - Ignore irrelevant or duplicate items +- Use elementContext to describe where a step is located when it helps the user - Some steps may be missing - Use other tools if needed to clarify or validate steps`, schema: z.object({ diff --git a/apps/backend/src/agent/tools/ToolGetPageSummary.ts b/apps/backend/src/agent/tools/ToolGetPageSummary.ts index 3db0823..e8f0912 100644 --- a/apps/backend/src/agent/tools/ToolGetPageSummary.ts +++ b/apps/backend/src/agent/tools/ToolGetPageSummary.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { AbstractCallableTool } from './AbstractCallableTool.ts'; import { PageContextProvider } from '@/indexer'; import type { ToolGetPageSummaryResultData } from '@/types'; -import { formatSampleHeadings, formatSampleInteractions } from '@flowforge/page-trail'; +import { semSampleHeadings, semSampleInteractions } from '@flowforge/page-trail'; export class ToolGetPageSummary extends AbstractCallableTool { private readonly elementsHeadingsLimit: number; @@ -21,8 +21,8 @@ export class ToolGetPageSummary extends AbstractCallableTool { url: ctx.pageTrail.basics.url, description: ctx.pageTrail.basics.description, language: ctx.pageTrail.basics.language, - sampleHeadings: formatSampleHeadings(ctx.pageTrail.content, this.elementsHeadingsLimit), - sampleInteractions: formatSampleInteractions(ctx.pageTrail.interactive, this.elementsInteractionsLimit), + sampleHeadings: semSampleHeadings(ctx.pageTrail.content, this.elementsHeadingsLimit), + sampleInteractions: semSampleInteractions(ctx.pageTrail.interactive, this.elementsInteractionsLimit), }; } diff --git a/apps/backend/src/agent/tools/ToolSearchInContent.ts b/apps/backend/src/agent/tools/ToolSearchInContent.ts index 90aa77e..2da7203 100644 --- a/apps/backend/src/agent/tools/ToolSearchInContent.ts +++ b/apps/backend/src/agent/tools/ToolSearchInContent.ts @@ -46,11 +46,14 @@ WHEN TO USE: WHAT IT RETURNS: - Relevant content fragments from the page -- Each fragment includes text and location context +- Each fragment includes text, elementContext, elementDataId, and optional elementCssSelector +- text is semantic page text for the matched content fragment +- elementContext contains semantic container breadcrumbs around the fragment, ordered from broader page area to nearer target area IMPORTANT: - Results are partial matches, not guaranteed answers - You must interpret and combine them into a final answer +- Use elementContext to describe where the supporting text appears when helpful - If needed, you can follow up with another tool to locate related elements`, schema: z.object({ query: z.string().describe('Topic to search for'), diff --git a/apps/backend/src/indexer/transformers/AbstractDocumentTransformer.ts b/apps/backend/src/indexer/transformers/AbstractDocumentTransformer.ts index 3535a2c..c7bdd48 100644 --- a/apps/backend/src/indexer/transformers/AbstractDocumentTransformer.ts +++ b/apps/backend/src/indexer/transformers/AbstractDocumentTransformer.ts @@ -1,6 +1,6 @@ import type { IndexableDocument, DocumentTransformer } from '@/types'; import { randomUUID } from 'crypto'; -import type { BaseElement, PageTrail } from '@flowforge/page-trail'; +import type { TargetElement, PageTrail } from '@flowforge/page-trail'; export abstract class AbstractDocumentTransformer implements DocumentTransformer { readonly name: string; @@ -15,7 +15,7 @@ export abstract class AbstractDocumentTransformer implements DocumentTransformer return randomUUID(); } - protected createDocument(content: string, el: BaseElement): IndexableDocument { + protected createDocument(content: string, el: TargetElement): IndexableDocument { return { id: this.createDocumentId(), content, diff --git a/apps/backend/src/indexer/transformers/ContentElementsTransformer.ts b/apps/backend/src/indexer/transformers/ContentElementsTransformer.ts index b27eb6a..1cb5318 100644 --- a/apps/backend/src/indexer/transformers/ContentElementsTransformer.ts +++ b/apps/backend/src/indexer/transformers/ContentElementsTransformer.ts @@ -1,11 +1,6 @@ import type { IndexableDocument } from '@/types'; import { AbstractDocumentTransformer } from './AbstractDocumentTransformer.ts'; -import { - type ContentElement, - type PageTrail, - formatContentElement, - formatContentElementShort, -} from '@flowforge/page-trail'; +import { type ContentElement, type PageTrail, semContentElement } from '@flowforge/page-trail'; import { RecursiveCharacterTextSplitter, TextSplitter } from '@langchain/textsplitters'; export const CONTENT_TEMPLATE_TEXT_PLACEHOLDER = '{{TEXT}}'; @@ -56,11 +51,12 @@ export class ContentElementsTransformer extends AbstractDocumentTransformer { } private createContentTemplate(el: ContentElement): string { - const template = formatContentElement(el, CONTENT_TEMPLATE_TEXT_PLACEHOLDER); + const sr = semContentElement(el, CONTENT_TEMPLATE_TEXT_PLACEHOLDER); + const template = sr.text(); // fallback if the template contains too many context data const templateContextSize = template.length - CONTENT_TEMPLATE_TEXT_PLACEHOLDER.length; if (templateContextSize > this.chunkSize * CONTENT_TEMPLATE_MAX_CONTEXT_RATIO) { - return formatContentElementShort(el, CONTENT_TEMPLATE_TEXT_PLACEHOLDER); + return sr.short(); } return template; } diff --git a/apps/backend/src/indexer/transformers/InteractiveElementsTransformer.ts b/apps/backend/src/indexer/transformers/InteractiveElementsTransformer.ts index 76241ea..0b5028f 100644 --- a/apps/backend/src/indexer/transformers/InteractiveElementsTransformer.ts +++ b/apps/backend/src/indexer/transformers/InteractiveElementsTransformer.ts @@ -1,6 +1,6 @@ import { AbstractDocumentTransformer } from './AbstractDocumentTransformer.ts'; import type { IndexableDocument } from '@/types'; -import { formatInteractiveElement, type PageTrail } from '@flowforge/page-trail'; +import { type PageTrail, semInteractiveElement } from '@flowforge/page-trail'; export class InteractiveElementsTransformer extends AbstractDocumentTransformer { constructor() { @@ -10,7 +10,7 @@ export class InteractiveElementsTransformer extends AbstractDocumentTransformer override async transformFn(pageTrail: PageTrail): Promise { const docs: IndexableDocument[] = []; for (const el of pageTrail.interactive) { - const content = formatInteractiveElement(el); + const content = semInteractiveElement(el).text(); docs.push(this.createDocument(content, el)); } return docs; diff --git a/apps/backend/src/types/documents.ts b/apps/backend/src/types/documents.ts index de90502..1b54d7f 100644 --- a/apps/backend/src/types/documents.ts +++ b/apps/backend/src/types/documents.ts @@ -1,10 +1,10 @@ -import type { BaseElement, ElementKind, PageTrail } from '@flowforge/page-trail'; +import type { ElementKind, TargetElement, PageTrail } from '@flowforge/page-trail'; export type DocumentType = ElementKind; export interface DocumentMetadata { type: DocumentType; - element: BaseElement; + element: TargetElement; } export interface Document { diff --git a/apps/backend/src/types/tools.ts b/apps/backend/src/types/tools.ts index 143317b..40c8309 100644 --- a/apps/backend/src/types/tools.ts +++ b/apps/backend/src/types/tools.ts @@ -28,15 +28,14 @@ export interface ToolGetPageSummaryResultData { url: string; description: string; language: string; - sampleHeadings: string; - sampleInteractions: string; + sampleHeadings: string[]; + sampleInteractions: string[]; } export interface ToolResultElement { - elementPath: string; - elementSectionName: string; elementDataId: string; - elementCssSelector: string; + elementContext: string[]; + elementCssSelector?: string; } export interface ToolFindElementFoundResultData extends ToolResultElement { diff --git a/apps/extension/README.md b/apps/extension/README.md index f430d21..ca10257 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -34,8 +34,6 @@ npm run sandbox 3. Click **Load unpacked** 4. Select `apps/extension/dist/chrome` -Sandbox opens `http://localhost:3007` with demo mode and backend mode. - ## Embed runtime `build:embed` creates a bundle and declaration file under `dist/embed`: @@ -55,11 +53,11 @@ await FlowForge.start({ settings: { theme: 'dark' } }); - `popup/` — user interface and interaction logic - `page/` — page overlay, highlighting, wizard, inspector, and collection hooks - `background/`, `chrome/`, `embed/` — worker, extension shell, and embed runtime -- `core/` / `adapters/` — API, storage, locator, root injection, and transport +- `core/` and `adapters/` — API, storage, locator, root injection, and transport ## Notes - Chrome extension requires backend on http://localhost:3477 -- Embed integration supports backend mode and demo mode +- Sandbox runs on http://localhost:3007 with backend and demo modes - Limited by browser security (iframes, cross-origin content) - See [Architecture](../../docs/ARCHITECTURE.md) for system design diff --git a/apps/extension/src/background/BackgroundWorker.ts b/apps/extension/src/background/BackgroundWorker.ts index fe58cf6..3d79313 100644 --- a/apps/extension/src/background/BackgroundWorker.ts +++ b/apps/extension/src/background/BackgroundWorker.ts @@ -2,7 +2,6 @@ import type { TransportService } from '@/adapters/interface'; import type { ApiClient } from '@/core/services/ApiClient'; import { HistoryStorage } from '@/core/services/HistoryStorage'; import { - type ApplySettingsMessage, type AskQuestionMessage, type AskQuestionMessageResponse, type ClearPageMessage, @@ -22,6 +21,7 @@ import { type OpenInspectorMessage, type OpenPageInspectorMessage, type PopupInitializeMessage, + type SettingsUpdatedMessage, type StartOnboardingMessage, type UpdateSettingsMessage, type UpdateSettingsMessageResponse, @@ -125,11 +125,14 @@ export class BackgroundWorker { private async handleUpdateSettings(message: UpdateSettingsMessage): Promise { try { const updatedSettings = await this.settingsStorage.update(message.data.patch); - // Apply updated settings to the page - await this.transport.sendToPage(message.senderId, { - type: 'APPLY_SETTINGS', - data: { settings: updatedSettings }, - }); + if (message.senderId !== undefined) { + void this.transport + .sendToPage(message.senderId, { + type: 'SETTINGS_UPDATED', + data: updatedSettings, + }) + .catch(() => undefined); + } return { success: true, data: updatedSettings }; } catch (error) { console.error('[Background] Error updating extension settings:', error); @@ -263,6 +266,7 @@ export class BackgroundWorker { // Open inspector await this.transport.sendToPage(message.senderId, { type: 'OPEN_INSPECTOR', + data: message.data, }); return { success: true }; } catch (error) { diff --git a/apps/extension/src/chrome/action/popup/popup.tsx b/apps/extension/src/chrome/action/popup/popup.tsx index 215fdf5..54408a6 100644 --- a/apps/extension/src/chrome/action/popup/popup.tsx +++ b/apps/extension/src/chrome/action/popup/popup.tsx @@ -11,15 +11,18 @@ import { Main } from '@/shared/components/Main'; import type { TransportService } from '@/adapters/interface'; function PopupAppRoot({ transport }: { transport: TransportService }) { - const { theme, toggleTheme } = useSettings({ transport }); + const settings = useSettings({ transport }); + if (settings.status === 'loading') { + return null; + } return ( -
+
window.close()} />
diff --git a/apps/extension/src/chrome/contentScripts/page.tsx b/apps/extension/src/chrome/contentScripts/page.tsx index d39ff3b..150bde9 100644 --- a/apps/extension/src/chrome/contentScripts/page.tsx +++ b/apps/extension/src/chrome/contentScripts/page.tsx @@ -11,11 +11,14 @@ import { useSettings } from '@/shared/hooks/useSettings'; import type { TransportService } from '@/adapters/interface'; function PageAppRoot({ transport }: { transport: TransportService }) { - const { theme } = useSettings({ transport }); + const settings = useSettings({ transport }); + if (settings.status === 'loading') { + return null; + } return ( -
- +
+
); } diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index 035647a..0233c3d 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -30,5 +30,6 @@ export const config: ExtensionConfig = { github: 'https://github.com/cerberus-ab/flowforge', defaultSettings: { theme: 'light', + devMode: false, }, }; diff --git a/apps/extension/src/core/types/messages.ts b/apps/extension/src/core/types/messages.ts index 70772fc..ddd18df 100644 --- a/apps/extension/src/core/types/messages.ts +++ b/apps/extension/src/core/types/messages.ts @@ -1,23 +1,18 @@ import type { AgentResultElement, AgentResultMode, PageTrail, QueryResponse } from '@flowforge/contract'; import type { ExtensionSettings } from '@/core/types/settings'; -type MessageTypeToBackground = 'GET_SETTINGS'; +type MessageTypeToBackground = 'GET_SETTINGS' | 'UPDATE_SETTINGS'; type MessageTypePopupToBackground = - | 'POPUP_INITIALISE' - | 'ASK_QUESTION' - | 'GET_PREV_QUESTIONS' - | 'NAVIGATE_TO_ELEMENT' - | 'UPDATE_SETTINGS' - | 'OPEN_PAGE_INSPECTOR'; + 'POPUP_INITIALISE' | 'ASK_QUESTION' | 'GET_PREV_QUESTIONS' | 'NAVIGATE_TO_ELEMENT' | 'OPEN_PAGE_INSPECTOR'; type MessageTypeBackgroundToPage = | 'COLLECT_PAGE_TRAIL' | 'START_ONBOARDING' | 'HIGHLIGHT_ELEMENT' | 'CLEAR_PAGE' - | 'APPLY_SETTINGS' - | 'OPEN_INSPECTOR'; + | 'OPEN_INSPECTOR' + | 'SETTINGS_UPDATED'; type MessageType = MessageTypeToBackground | MessageTypePopupToBackground | MessageTypeBackgroundToPage; @@ -36,26 +31,26 @@ export type GetSettingsMessageResponseData = ExtensionSettings; export type GetSettingsMessageResponse = MessageResponse; -// Popup -> Background - -export type PopupInitializeMessage = Message & { - type: 'POPUP_INITIALISE'; - senderId: number; -}; - export type UpdateSettingsMessageData = { patch: Partial; }; export type UpdateSettingsMessage = Message & { type: 'UPDATE_SETTINGS'; - senderId: number; + senderId?: number; }; export type UpdateSettingsMessageResponseData = ExtensionSettings; export type UpdateSettingsMessageResponse = MessageResponse; +// Popup -> Background + +export type PopupInitializeMessage = Message & { + type: 'POPUP_INITIALISE'; + senderId: number; +}; + export interface AskQuestionMessageData { question: string; } @@ -89,19 +84,13 @@ export type NavigateToElementMessage = Message & { senderId: number; }; -export type OpenPageInspectorMessage = Message & { - type: 'OPEN_PAGE_INSPECTOR'; - senderId: number; -}; - -// Background -> Page - -export interface ApplySettingsMessageData { - settings: ExtensionSettings; +export interface OpenPageInspectorMessageData { + tab?: string; } -export type ApplySettingsMessage = Message & { - type: 'APPLY_SETTINGS'; +export type OpenPageInspectorMessage = Message & { + type: 'OPEN_PAGE_INSPECTOR'; + senderId: number; }; export type CollectPageTrailMessage = Message & { @@ -135,10 +124,16 @@ export type HighlightElementMessage = Message & { type: 'HIGHLIGHT_ELEMENT'; }; -export type OpenInspectorMessage = Message & { +export type OpenInspectorMessage = Message & { type: 'OPEN_INSPECTOR'; }; +export type SettingsUpdatedMessageData = ExtensionSettings; + +export type SettingsUpdatedMessage = Message & { + type: 'SETTINGS_UPDATED'; +}; + // Type guards export function isPopupInitializeMessage(message: Message): message is PopupInitializeMessage { @@ -153,10 +148,6 @@ export function isUpdateSettingsMessage(message: Message): message is UpdateSett return message.type === 'UPDATE_SETTINGS'; } -export function isApplySettingsMessage(message: Message): message is ApplySettingsMessage { - return message.type === 'APPLY_SETTINGS'; -} - export function isAskQuestionMessage(message: Message): message is AskQuestionMessage { return message.type === 'ASK_QUESTION'; } @@ -192,3 +183,7 @@ export function isNavigateToElementMessage(message: Message): message is Navigat export function isHighlightElementMessage(message: Message): message is HighlightElementMessage { return message.type === 'HIGHLIGHT_ELEMENT'; } + +export function isSettingsUpdatedMessage(message: Message): message is SettingsUpdatedMessage { + return message.type === 'SETTINGS_UPDATED'; +} diff --git a/apps/extension/src/core/types/settings.ts b/apps/extension/src/core/types/settings.ts index 8e36549..f670c77 100644 --- a/apps/extension/src/core/types/settings.ts +++ b/apps/extension/src/core/types/settings.ts @@ -2,4 +2,5 @@ export type ExtensionSettingsTheme = 'light' | 'dark'; export interface ExtensionSettings { theme: ExtensionSettingsTheme; + devMode: boolean; } diff --git a/apps/extension/src/embed/Runtime.tsx b/apps/extension/src/embed/Runtime.tsx index 9a1332d..00c0c65 100644 --- a/apps/extension/src/embed/Runtime.tsx +++ b/apps/extension/src/embed/Runtime.tsx @@ -40,7 +40,7 @@ interface MountShellOptions { interface RuntimeApi { openPopup(question?: string): void; closePopup(): void; - openPageInspector(): Promise; + openPageInspector(tab?: string): Promise; destroy(): void; } @@ -118,10 +118,11 @@ export class Runtime implements RuntimeApi { this.shellRef.current?.close(); } - async openPageInspector(): Promise { + async openPageInspector(tab?: string): Promise { const message: OpenPageInspectorMessage = { type: 'OPEN_PAGE_INSPECTOR', senderId: await this.transport.getActiveSenderId(), + data: { tab }, }; await this.transport.sendToBackground(message); } @@ -149,6 +150,11 @@ export class Runtime implements RuntimeApi { private async mountShell(options: MountShellOptions): Promise { const rootInjector = new ShadowRootInjector(); + let resolveShellReady!: () => void; + const shellReady = new Promise((resolve) => { + resolveShellReady = resolve; + }); + const doMount = () => { const shellRoot = rootInjector.inject(document, embedConstants.SHELL_ROOT_ID, { overlay: true }); rootInjector.injectStyles(shellRoot, shellStyles); @@ -158,6 +164,7 @@ export class Runtime implements RuntimeApi { transport={this.transport} triggerSize={options.triggerSize} demoProps={options.demoProps} + onShellReady={resolveShellReady} />, shellRoot.mountPoint, ); @@ -175,7 +182,7 @@ export class Runtime implements RuntimeApi { } else { doMount(); } - await new Promise((resolve) => queueMicrotask(resolve)); + await shellReady; } private unmountShell(): void { diff --git a/apps/extension/src/embed/flowforge-runtime.d.ts b/apps/extension/src/embed/flowforge-runtime.d.ts index 387371c..48d2390 100644 --- a/apps/extension/src/embed/flowforge-runtime.d.ts +++ b/apps/extension/src/embed/flowforge-runtime.d.ts @@ -11,6 +11,7 @@ export interface FlowForgeRuntime { triggerSize?: 'medium' | 'large'; settings?: { theme?: 'light' | 'dark'; + devMode?: boolean; }; }): Promise; @@ -18,6 +19,7 @@ export interface FlowForgeRuntime { triggerSize?: 'medium' | 'large'; settings?: { theme?: 'light' | 'dark'; + devMode?: boolean; }; topic?: string; stubModel?: string; @@ -31,7 +33,7 @@ export interface FlowForgeRuntime { export interface FlowForgeInstance { openPopup(question?: string): void; closePopup(): void; - openPageInspector(): Promise; + openPageInspector(tab?: string): Promise; destroy(): void; } diff --git a/apps/extension/src/embed/shell/ShellApp.tsx b/apps/extension/src/embed/shell/ShellApp.tsx index 2743724..67dc723 100644 --- a/apps/extension/src/embed/shell/ShellApp.tsx +++ b/apps/extension/src/embed/shell/ShellApp.tsx @@ -16,6 +16,7 @@ export interface ShellAppProps { transport: TransportService; demoProps?: ShellAppDemoProps; triggerSize?: TriggerSize; + onShellReady?: () => void; } export interface ShellAppRef { @@ -24,12 +25,12 @@ export interface ShellAppRef { } export const ShellApp = forwardRef(function ShellApp( - { transport, demoProps, triggerSize }, + { transport, demoProps, triggerSize, onShellReady }, ref, ) { const [isOpen, setIsOpen] = useState(false); const [initialQuestion, setInitialQuestion] = useState(); - const { theme, toggleTheme } = useSettings({ transport }); + const settings = useSettings({ transport }); const triggerRef = useRef(null); const popupRef = useRef(null); @@ -95,10 +96,18 @@ export const ShellApp = forwardRef(function ShellApp }; }, [isOpen, closePopup]); + if (settings.status === 'loading') { + return null; + } return ( -
+
- + {isOpen && (
@@ -106,8 +115,8 @@ export const ShellApp = forwardRef(function ShellApp variant="dialog" transport={transport} demoProps={demoProps} - theme={theme} - onToggleTheme={toggleTheme} + theme={settings.theme} + onToggleTheme={settings.toggleTheme} initialQuestion={initialQuestion} onClose={closePopup} /> diff --git a/apps/extension/src/page/PageApp.tsx b/apps/extension/src/page/PageApp.tsx index c7645d1..79417ea 100644 --- a/apps/extension/src/page/PageApp.tsx +++ b/apps/extension/src/page/PageApp.tsx @@ -7,10 +7,13 @@ import { Inspector } from '@/page/components/Inspector'; export interface PageAppProps { transport: TransportService; + devMode: boolean; + onDevModeChange: (enabled: boolean) => void | Promise; + onReady?: () => void; } -export function PageApp({ transport }: PageAppProps) { - const { highlights, wizard, inspector } = usePage({ transport }); +export function PageApp({ transport, devMode, onDevModeChange, onReady }: PageAppProps) { + const { highlights, wizard, inspector } = usePage({ transport, devMode, onDevModeChange, onReady }); return (
diff --git a/apps/extension/src/page/components/Inspector/Inspector.css b/apps/extension/src/page/components/Inspector/Inspector.css index 634396c..1ee8433 100644 --- a/apps/extension/src/page/components/Inspector/Inspector.css +++ b/apps/extension/src/page/components/Inspector/Inspector.css @@ -42,6 +42,12 @@ padding: var(--flowforge-pad-lg) var(--flowforge-pad-lg) var(--flowforge-pad-xl) var(--flowforge-pad-lg); } +.flowforge-inspector__header-ctrl { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: var(--flowforge-pad-sm); +} .flowforge-inspector__header-title { margin: 0 0 var(--flowforge-pad-lg); font: var(--flowforge-text-h3); diff --git a/apps/extension/src/page/components/Inspector/Inspector.tsx b/apps/extension/src/page/components/Inspector/Inspector.tsx index e0a7b41..7915676 100644 --- a/apps/extension/src/page/components/Inspector/Inspector.tsx +++ b/apps/extension/src/page/components/Inspector/Inspector.tsx @@ -1,44 +1,102 @@ import type { TargetedPointerEvent } from 'preact'; -import { useEffect, useId, useState } from 'preact/hooks'; -import { BadgeInfo, BookOpenText, FileText, MousePointerClick } from 'lucide-preact'; +import { useEffect, useId, useMemo, useState } from 'preact/hooks'; +import { + type LucideIcon, + BadgeInfo, + BookOpenText, + ChartNoAxesColumn, + FileText, + ListTree, + MousePointerClick, +} from 'lucide-preact'; import { getEventTarget } from '@/core/utils/dom'; import type { InspectorViewModel } from '@/page/hooks/usePage'; import { Button } from '@/shared/components/Button'; import { JsonViewer } from '@/shared/components/JsonViewer'; import { MarkdownViewer } from '@/shared/components/MarkdownViewer'; +import { Switch } from '@/shared/components/Switch'; import { Tabs } from '@/shared/components/Tabs'; -import { PageMetadata } from '@/page/components/Inspector/components/Metadata'; -import { formatContentElement, formatInteractiveElement, generateSemanticMarkdown } from '@flowforge/page-trail'; +import { Tooltip } from '@/shared/components/Tooltip'; +import { InspectorPageMetadata } from '@/page/components/Inspector/components/Metadata'; +import { semMarkdown } from '@flowforge/page-trail'; +import { + InspectorPageStructure, + InspectorPageContent, + InspectorPageInteractive, +} from '@/page/components/Inspector/components/Elements'; -const inspectorTabs = [ - { id: 'basics', label: 'Basics', icon: BadgeInfo }, - { id: 'content', label: 'Content', icon: BookOpenText }, - { id: 'interactive', label: 'Interactive', icon: MousePointerClick }, - { id: 'semanticView', label: 'Semantic view', icon: FileText }, -] as const; +type InspectorTab = { + id: 'basics' | 'structure' | 'content' | 'interactive' | 'markdown' | 'metadata'; + label: string; + icon: LucideIcon; + tooltip: string; + devModeOnly?: boolean; +}; -type InspectorTabId = (typeof inspectorTabs)[number]['id']; +const inspectorTabs: InspectorTab[] = [ + { + id: 'basics', + label: 'Basics', + icon: BadgeInfo, + tooltip: 'Page URL, title, description, language, and viewport.', + }, + { + id: 'structure', + label: 'Structure', + icon: ListTree, + tooltip: 'Detected landmarks, sections, forms, and dialogs as a tree.', + }, + { + id: 'content', + label: 'Content', + icon: BookOpenText, + tooltip: 'Top text and heading elements with scores and page context.', + }, + { + id: 'interactive', + label: 'Interactive', + icon: MousePointerClick, + tooltip: 'Top buttons, links, inputs, and controls with labels, state, and context.', + }, + { + id: 'markdown', + label: 'Markdown', + icon: FileText, + tooltip: 'Human-readable semantic snapshot used for inspection and copy.', + }, + { + id: 'metadata', + label: 'Metadata', + icon: ChartNoAxesColumn, + tooltip: 'Collection counts, limit flags, timing, depth, and timestamp.', + devModeOnly: true, + }, +]; -function getSemanticDescription(value: unknown): string | undefined { - if ( - typeof value === 'object' && - value !== null && - 'importanceScore' in value && - 'semanticDescription' in value && - typeof value.importanceScore === 'number' && - typeof value.semanticDescription === 'string' - ) { - return `${value.importanceScore.toFixed(2)} · ${value.semanticDescription}`; - } - return undefined; +function resolveInspectorTabId(tabs: readonly InspectorTab[], preferredTab?: string): InspectorTab['id'] { + const tab = tabs.find((item) => item.id === preferredTab); + + return tab?.id ?? tabs[0]!.id; } -export function Inspector({ pageTrail, close }: InspectorViewModel) { - const [activeTab, setActiveTab] = useState('basics'); +// Exports + +export function Inspector({ pageTrail, initialTab, close, devMode, onDevModeChange }: InspectorViewModel) { + const availableTabs = useMemo(() => inspectorTabs.filter((tab) => !tab.devModeOnly || devMode), [devMode]); + const [activeTab, setActiveTab] = useState(() => + resolveInspectorTabId(availableTabs, initialTab), + ); const tabsIdPrefix = `flowforge-inspector-tabs-${useId()}`; const getTabId = (id: string) => `${tabsIdPrefix}-tab-${id}`; const getPanelId = (id: string) => `${tabsIdPrefix}-panel-${id}`; + // Reset the active tab + useEffect(() => { + if (availableTabs.some((tab) => tab.id === activeTab)) return; + + setActiveTab(resolveInspectorTabId(availableTabs, initialTab)); + }, [activeTab, availableTabs, initialTab]); + // Close on esc useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -83,6 +141,12 @@ export function Inspector({ pageTrail, close }: InspectorViewModel) {

+ + + @@ -90,9 +154,9 @@ export function Inspector({ pageTrail, close }: InspectorViewModel) {
setActiveTab(id as InspectorTabId)} + onChange={(id) => setActiveTab(id as InspectorTab['id'])} getTabId={getTabId} getPanelId={getPanelId} autoFocus @@ -105,32 +169,18 @@ export function Inspector({ pageTrail, close }: InspectorViewModel) { aria-labelledby={getTabId(activeTab)} > {activeTab === 'basics' && } - {activeTab === 'content' && ( - ({ - ...contentElement, - semanticDescription: formatContentElement(contentElement), - }))} - /> + {activeTab === 'structure' && ( + )} + {activeTab === 'content' && } {activeTab === 'interactive' && ( - ({ - ...interactiveElement, - semanticDescription: formatInteractiveElement(interactiveElement), - }))} - /> + )} - {activeTab === 'semanticView' && } + {activeTab === 'markdown' && } + {activeTab === 'metadata' && devMode && }
- +
diff --git a/apps/extension/src/page/components/Inspector/components/Elements/InspectorPageElements.tsx b/apps/extension/src/page/components/Inspector/components/Elements/InspectorPageElements.tsx new file mode 100644 index 0000000..1b563b6 --- /dev/null +++ b/apps/extension/src/page/components/Inspector/components/Elements/InspectorPageElements.tsx @@ -0,0 +1,73 @@ +import { + type PageTrail, + semModelEnrichedContent, + semModelEnrichedInteractive, + semModelEnrichedStructure, + semModelPreviewContent, + semModelPreviewInteractive, + semModelPreviewStructure, +} from '@flowforge/page-trail'; +import { JsonViewer } from '@/shared/components/JsonViewer'; + +// "importanceScore.value · semanticText" +function getPageElementSummary(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + + const obj = value as Record; + if (typeof obj.semanticText !== 'string') return undefined; + + const importance = + typeof obj.importanceScore === 'object' && obj.importanceScore !== null + ? (obj.importanceScore as Record).value + : undefined; + const score = typeof importance === 'number' && Number.isFinite(importance) ? importance.toFixed(2) : undefined; + + return score ? `${score} · ${obj.semanticText}` : obj.semanticText; +} + +// Exports + +export function InspectorPageStructure({ + structure, + devMode, +}: { + structure: PageTrail['structure']; + devMode: boolean; +}) { + return ( + + ); +} + +export function InspectorPageContent({ content, devMode }: { content: PageTrail['content']; devMode: boolean }) { + return ( + + ); +} + +export function InspectorPageInteractive({ + interactive, + devMode, +}: { + interactive: PageTrail['interactive']; + devMode: boolean; +}) { + return ( + + ); +} diff --git a/apps/extension/src/page/components/Inspector/components/Elements/index.ts b/apps/extension/src/page/components/Inspector/components/Elements/index.ts new file mode 100644 index 0000000..a146077 --- /dev/null +++ b/apps/extension/src/page/components/Inspector/components/Elements/index.ts @@ -0,0 +1 @@ +export { InspectorPageStructure, InspectorPageContent, InspectorPageInteractive } from './InspectorPageElements.tsx'; diff --git a/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx b/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx new file mode 100644 index 0000000..3554592 --- /dev/null +++ b/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx @@ -0,0 +1,36 @@ +import type { PageTrail } from '@flowforge/contract'; +import { Tooltip } from '@/shared/components/Tooltip'; + +const limitTooltip = 'Only a limited number of top candidates by importance are selected.'; + +// Exports + +export function InspectorPageMetadata({ metadata }: { metadata: PageTrail['metadata']; devMode: boolean }) { + return ( +
+ Selected{' '} + {metadata.contentElementsLimitReached ? ( + <> + + {metadata.contentElements} + + /{metadata.contentElementsTotal} + + ) : ( + <>{metadata.contentElements} + )}{' '} + content elements,{' '} + {metadata.interactiveElementsLimitReached ? ( + <> + + {metadata.interactiveElements} + + /{metadata.interactiveElementsTotal} + + ) : ( + <>{metadata.interactiveElements} + )}{' '} + interactive elements · {metadata.performance.totalMs}ms +
+ ); +} diff --git a/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx b/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx deleted file mode 100644 index 85d7739..0000000 --- a/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { PageTrail } from '@flowforge/contract'; - -export interface PageMetadataProps { - metadata: PageTrail['metadata']; -} - -export function PageMetadata({ metadata }: PageMetadataProps) { - return ( -
- Selected{' '} - {metadata.contentElementsLimitReached ? ( - <> - - {metadata.contentElements} - - /{metadata.contentElementsTotal} - - ) : ( - <>{metadata.contentElements} - )}{' '} - content elements,{' '} - {metadata.interactiveElementsLimitReached ? ( - <> - - {metadata.interactiveElements} - - /{metadata.interactiveElementsTotal} - - ) : ( - <>{metadata.interactiveElements} - )}{' '} - interactive elements · {metadata.durationMs}ms -
- ); -} diff --git a/apps/extension/src/page/components/Inspector/components/Metadata/index.tsx b/apps/extension/src/page/components/Inspector/components/Metadata/index.tsx index 2add8c0..8ce11f4 100644 --- a/apps/extension/src/page/components/Inspector/components/Metadata/index.tsx +++ b/apps/extension/src/page/components/Inspector/components/Metadata/index.tsx @@ -1 +1 @@ -export { PageMetadata } from './PageMetadata'; +export { InspectorPageMetadata } from './InspectorPageMetadata.tsx'; diff --git a/apps/extension/src/page/hooks/usePage.ts b/apps/extension/src/page/hooks/usePage.ts index 314bb8e..a5b4d88 100644 --- a/apps/extension/src/page/hooks/usePage.ts +++ b/apps/extension/src/page/hooks/usePage.ts @@ -6,7 +6,7 @@ import { isStartOnboardingMessage, } from '@/types'; import type { Message, StartOnboardingMessageData } from '@/types'; -import { useCallback, useLayoutEffect, useState } from 'preact/hooks'; +import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks'; import { findElement, getOrCreateDataId } from '@/core/locator/locate'; import type { TransportService } from '@/adapters/interface'; import { constants } from '@/constants'; @@ -23,6 +23,9 @@ function collectPageTrail(): PageTrail { export interface UsePageOptions { transport: TransportService; + devMode: boolean; + onDevModeChange: (enabled: boolean) => void | Promise; + onReady?: () => void; } interface HighlightState { @@ -51,10 +54,13 @@ export interface WizardViewModel extends WizardState { interface InspectorState { pageTrail: PageTrail; + initialTab?: string; } export interface InspectorViewModel extends InspectorState { close: () => void; + devMode: boolean; + onDevModeChange: (enabled: boolean) => void | Promise; } export interface PageViewModel { @@ -63,10 +69,11 @@ export interface PageViewModel { inspector: InspectorViewModel | null; } -export function usePage({ transport }: UsePageOptions): PageViewModel { +export function usePage({ transport, devMode, onDevModeChange, onReady }: UsePageOptions): PageViewModel { const [highlights, setHighlights] = useState([]); const [wizard, setWizard] = useState(null); const [inspector, setInspector] = useState(null); + const readyRef = useRef(false); const closeWizard = useCallback(() => { setHighlights([]); @@ -122,8 +129,8 @@ export function usePage({ transport }: UsePageOptions): PageViewModel { ); // Open inspector - const openInspector = useCallback((pageTrail: PageTrail) => { - setInspector({ pageTrail }); + const openInspector = useCallback((pageTrail: PageTrail, initialTab?: string) => { + setInspector({ pageTrail, initialTab }); }, []); // Handle wizard step change @@ -165,7 +172,7 @@ export function usePage({ transport }: UsePageOptions): PageViewModel { // Listen to messages from background useLayoutEffect(() => { - return transport.addMessageListener((message: Message) => { + const unsubscribe = transport.addMessageListener((message: Message) => { if (isCollectPageTrailMessage(message)) { const pageTrail = collectPageTrail(); return { success: true, data: pageTrail }; @@ -184,12 +191,18 @@ export function usePage({ transport }: UsePageOptions): PageViewModel { } if (isOpenInspectorMessage(message)) { const pageTrail = collectPageTrail(); - openInspector(pageTrail); + openInspector(pageTrail, message.data.tab); return { success: true }; } return undefined; }); - }, [transport, startOnboarding, highlightElement, openInspector, clearPage]); + + if (!readyRef.current) { + readyRef.current = true; + onReady?.(); + } + return unsubscribe; + }, [transport, startOnboarding, highlightElement, openInspector, clearPage, onReady]); return { highlights: highlights.map((highlight) => ({ @@ -207,6 +220,8 @@ export function usePage({ transport }: UsePageOptions): PageViewModel { ? { ...inspector, close: closeInspector, + devMode, + onDevModeChange, } : null, }; diff --git a/apps/extension/src/popup/PopupApp.tsx b/apps/extension/src/popup/PopupApp.tsx index 6b47fa1..5a2f13b 100644 --- a/apps/extension/src/popup/PopupApp.tsx +++ b/apps/extension/src/popup/PopupApp.tsx @@ -6,7 +6,7 @@ import { Question } from './components/Question'; import { Loading } from './components/Loading'; import { Result } from './components/Result'; import { Examples } from './components/Examples'; -import { Developer } from '@/popup/components/Developer'; +import { Understanding } from '@/popup/components/Understanding'; import { ButtonText } from '@/shared/components/Button'; import { Link } from '@/shared/components/Link'; import { Notice } from '@/shared/components/Notice'; @@ -77,10 +77,13 @@ export function PopupApp({ ); // Handle open page inspector and close popup - const handleOpenPageInspector = useCallback(() => { - openPageInspector(); - onClose?.(); - }, [openPageInspector, onClose]); + const handleOpenPageInspector = useCallback( + (tab?: string) => { + openPageInspector(tab); + onClose?.(); + }, + [openPageInspector, onClose], + ); const isDialog = variant === 'dialog'; const Root = isDialog ? 'section' : 'div'; @@ -136,7 +139,7 @@ export function PopupApp({ - +
{copyright}
diff --git a/apps/extension/src/popup/components/Developer/Developer.css b/apps/extension/src/popup/components/Developer/Developer.css deleted file mode 100644 index adf8795..0000000 --- a/apps/extension/src/popup/components/Developer/Developer.css +++ /dev/null @@ -1,16 +0,0 @@ -/* Developer */ -.flowforge-developer-list { -} -.flowforge-developer-list ul { - list-style: none; - display: flex; - flex-direction: column; - margin: 0; - padding: 0; - gap: var(--flowforge-pad-xs); -} -.flowforge-developer-list li { - margin: 0; - padding: 0; - font: var(--flowforge-text-sm); -} diff --git a/apps/extension/src/popup/components/Developer/Developer.tsx b/apps/extension/src/popup/components/Developer/Developer.tsx deleted file mode 100644 index 883db96..0000000 --- a/apps/extension/src/popup/components/Developer/Developer.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Card } from '@/shared/components/Card'; -import { ButtonText } from '@/shared/components/Button'; - -interface DeveloperProps { - onOpenPageInspector: () => void; -} - -export function Developer({ onOpenPageInspector }: DeveloperProps) { - return ( - -
-
    -
  • - - Inspect page context - -
  • -
-
-
- ); -} diff --git a/apps/extension/src/popup/components/Developer/index.ts b/apps/extension/src/popup/components/Developer/index.ts deleted file mode 100644 index 43044c9..0000000 --- a/apps/extension/src/popup/components/Developer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Developer } from './Developer'; diff --git a/apps/extension/src/popup/components/Examples/Examples.css b/apps/extension/src/popup/components/Examples/Examples.css index da7d11b..eedba61 100644 --- a/apps/extension/src/popup/components/Examples/Examples.css +++ b/apps/extension/src/popup/components/Examples/Examples.css @@ -18,7 +18,7 @@ align-items: center; padding: var(--flowforge-pad-xs) var(--flowforge-pad-sm); border-radius: var(--flowforge-pad-xs); - background: var(--flowforge-color-surface); + background: transparent; border: 1px solid var(--flowforge-color-border); color: var(--flowforge-color-text-muted); font: var(--flowforge-text-sm); @@ -26,9 +26,6 @@ cursor: pointer; transition: var(--flowforge-transition-default); } -.flowforge-example-chip:hover { - background: var(--flowforge-color-bg); -} .flowforge-example-chip--primary:hover { border-color: var(--flowforge-color-primary-hover); color: var(--flowforge-color-primary-hover); diff --git a/apps/extension/src/popup/components/Understanding/Understanding.css b/apps/extension/src/popup/components/Understanding/Understanding.css new file mode 100644 index 0000000..d691bcf --- /dev/null +++ b/apps/extension/src/popup/components/Understanding/Understanding.css @@ -0,0 +1,19 @@ +/* Understanding */ +.flowforge-understanding-container { + background: transparent; +} +.flowforge-understanding-list { +} +.flowforge-understanding-list ul { + list-style: none; + display: flex; + flex-direction: row; + margin: 0; + padding: 0; + gap: var(--flowforge-pad-xs); +} +.flowforge-understanding-list li { + margin: 0; + padding: 0; + font: var(--flowforge-text-sm); +} diff --git a/apps/extension/src/popup/components/Understanding/Understanding.tsx b/apps/extension/src/popup/components/Understanding/Understanding.tsx new file mode 100644 index 0000000..0021a71 --- /dev/null +++ b/apps/extension/src/popup/components/Understanding/Understanding.tsx @@ -0,0 +1,43 @@ +import { FileText, ListTree } from 'lucide-preact'; +import { Card } from '@/shared/components/Card'; +import { Button } from '@/shared/components/Button'; +import { Tooltip } from '@/shared/components/Tooltip'; + +interface UnderstandingProps { + onOpenPageInspector: (tab?: string) => void; +} + +export function Understanding({ onOpenPageInspector }: UnderstandingProps) { + return ( + +
+
    +
  • + + + +
  • +
  • + + + +
  • +
+
+
+ ); +} diff --git a/apps/extension/src/popup/components/Understanding/index.ts b/apps/extension/src/popup/components/Understanding/index.ts new file mode 100644 index 0000000..67fd303 --- /dev/null +++ b/apps/extension/src/popup/components/Understanding/index.ts @@ -0,0 +1 @@ +export { Understanding } from './Understanding.tsx'; diff --git a/apps/extension/src/popup/hooks/usePopup.ts b/apps/extension/src/popup/hooks/usePopup.ts index f5aa57f..30add3c 100644 --- a/apps/extension/src/popup/hooks/usePopup.ts +++ b/apps/extension/src/popup/hooks/usePopup.ts @@ -36,7 +36,7 @@ export interface PopupViewModel { askQuestion: () => Promise; applyExampleQuestion: (question: string) => void; navigateToElement: (element: AgentResultElement) => void; - openPageInspector: () => void; + openPageInspector: (tab?: string) => void; } export function usePopup({ transport, presetQuestions, initialQuestion }: UsePopupOptions): PopupViewModel { @@ -165,13 +165,17 @@ export function usePopup({ transport, presetQuestions, initialQuestion }: UsePop ); // Handle open inspector - const handleOpenPageInspector = useCallback(async () => { - const message: OpenPageInspectorMessage = { - type: 'OPEN_PAGE_INSPECTOR', - senderId: await transport.getActiveSenderId(), - }; - await transport.sendToBackground(message); - }, [transport]); + const handleOpenPageInspector = useCallback( + async (tab?: string) => { + const message: OpenPageInspectorMessage = { + type: 'OPEN_PAGE_INSPECTOR', + senderId: await transport.getActiveSenderId(), + data: { tab }, + }; + await transport.sendToBackground(message); + }, + [transport], + ); return { question, diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css index 6a8520e..ee3b129 100644 --- a/apps/extension/src/popup/popup.css +++ b/apps/extension/src/popup/popup.css @@ -4,7 +4,7 @@ @import './components/Loading/Loading.css'; @import './components/Result/Result.css'; @import './components/Examples/Examples.css'; -@import './components/Developer/Developer.css'; +@import 'components/Understanding/Understanding.css'; /* Popup */ .flowforge-popup { diff --git a/apps/extension/src/shared/components/Button/Button.css b/apps/extension/src/shared/components/Button/Button.css index 910f326..78f613f 100644 --- a/apps/extension/src/shared/components/Button/Button.css +++ b/apps/extension/src/shared/components/Button/Button.css @@ -1,11 +1,15 @@ /* Button */ .flowforge-button { appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--flowforge-pad-xs); padding: var(--flowforge-pad-sm) var(--flowforge-pad-lg); + border: 1px solid transparent; border-radius: var(--flowforge-pad-xs); font: var(--flowforge-text-md); cursor: pointer; - border: none; transition: var(--flowforge-transition-default); } .flowforge-button--sm { @@ -21,27 +25,47 @@ .flowforge-button--wide { width: 100%; } -.flowforge-button, -.flowforge-button--primary { - background: var(--flowforge-color-primary); +.flowforge-button--icon-end { + flex-direction: row-reverse; +} +.flowforge-button--solid { color: var(--flowforge-color-text-on-dark); } -.flowforge-button--secondary { +.flowforge-button--solid.flowforge-button--primary { + background: var(--flowforge-color-primary); +} +.flowforge-button--solid.flowforge-button--secondary { background: var(--flowforge-color-secondary); } -.flowforge-button:hover, -.flowforge-button--primary:hover { +.flowforge-button--solid.flowforge-button--primary:not(:disabled):hover { background: var(--flowforge-color-primary-hover); transform: translateY(-1px); } -.flowforge-button--secondary:hover { +.flowforge-button--solid.flowforge-button--secondary:not(:disabled):hover { background: var(--flowforge-color-secondary-hover); + transform: translateY(-1px); } -.flowforge-button:active { +.flowforge-button--solid:not(:disabled):active { transform: translateY(0); } -.flowforge-button:disabled { +.flowforge-button--solid:disabled { background: var(--flowforge-color-muted); cursor: not-allowed; opacity: 0.5; } +.flowforge-button--ghost { + background: var(--flowforge-color-surface); + color: var(--flowforge-color-text-muted); +} +.flowforge-button--ghost:not(:disabled):hover { + color: var(--flowforge-color-text); + border-color: var(--flowforge-color-border); +} +.flowforge-button--ghost:focus-visible { + outline: 2px solid rgba(var(--flowforge-rgb-primary), 0.45); +} +.flowforge-button--ghost:disabled { + background: transparent; + cursor: not-allowed; + opacity: 0.5; +} diff --git a/apps/extension/src/shared/components/Button/Button.tsx b/apps/extension/src/shared/components/Button/Button.tsx index a5059dd..e7fddd9 100644 --- a/apps/extension/src/shared/components/Button/Button.tsx +++ b/apps/extension/src/shared/components/Button/Button.tsx @@ -1,21 +1,57 @@ import type { ComponentProps } from 'preact'; +import { forwardRef } from 'preact/compat'; +import type { LucideIcon } from 'lucide-preact'; +import { Icon } from '@/shared/components/Icon'; -interface ButtonProps extends ComponentProps<'button'> { - variant?: 'primary' | 'secondary'; +type ButtonOwnProps = { size?: 'small' | 'medium' | 'large'; + icon?: LucideIcon; + iconPosition?: 'start' | 'end'; wide?: boolean; -} +} & ( + | { + appearance?: 'solid'; + variant?: 'primary' | 'secondary'; + } + | { + appearance: 'ghost'; + variant?: never; + } +); -export function Button({ variant = 'primary', size = 'medium', wide = false, ...props }: ButtonProps) { +type ButtonProps = ComponentProps<'button'> & ButtonOwnProps; + +export const Button = forwardRef(function Button( + { + appearance = 'solid', + variant = 'primary', + size = 'medium', + icon, + iconPosition = 'start', + wide = false, + className, + children, + ...props + }, + ref, +) { const classes = [ 'flowforge-button', - `flowforge-button--${variant}`, + `flowforge-button--${appearance}`, + appearance === 'solid' && `flowforge-button--${variant}`, size === 'small' && 'flowforge-button--sm', size === 'large' && 'flowforge-button--lg', + icon && iconPosition === 'end' && 'flowforge-button--icon-end', wide && 'flowforge-button--wide', + className, ] .filter(Boolean) .join(' '); - return + ); +}); diff --git a/apps/extension/src/shared/components/Card/Card.tsx b/apps/extension/src/shared/components/Card/Card.tsx index aebd853..29f4105 100644 --- a/apps/extension/src/shared/components/Card/Card.tsx +++ b/apps/extension/src/shared/components/Card/Card.tsx @@ -8,6 +8,7 @@ interface CardProps { direction?: 'none' | 'left'; twinkle?: boolean; error?: boolean; + className?: string; children?: ComponentChildren; } @@ -18,23 +19,25 @@ export function Card({ direction = 'none', twinkle = false, error = false, + className, children, }: CardProps) { - const className = [ + const classNames = [ 'flowforge-card', `flowforge-card--${variant}`, direction !== 'none' && `flowforge-card--${direction}`, twinkle && 'flowforge-stared-twinkle', twinkle && `flowforge-stared-twinkle--${variant}`, + className, ] .filter(Boolean) .join(' '); - const id = useId(); - const titleId = title ? `flowforge-card-title-${id}` : undefined; + + const titleId = title ? `flowforge-card-title-${useId()}` : undefined; return (
, + 'aria-checked' | 'children' | 'className' | 'onChange' | 'onClick' | 'role' +> { + checked: boolean; + label: string; + onCheckedChange: (checked: boolean) => void | Promise; +} + +export function Switch({ checked, label, onCheckedChange, disabled, ...props }: SwitchProps) { + const labelId = `flowforge-switch-label-${useId()}`; + + return ( + + ); +} diff --git a/apps/extension/src/shared/components/Switch/index.ts b/apps/extension/src/shared/components/Switch/index.ts new file mode 100644 index 0000000..cee89a1 --- /dev/null +++ b/apps/extension/src/shared/components/Switch/index.ts @@ -0,0 +1 @@ +export { Switch } from './Switch'; diff --git a/apps/extension/src/shared/components/Tabs/Tabs.css b/apps/extension/src/shared/components/Tabs/Tabs.css index eff7899..5e8eb37 100644 --- a/apps/extension/src/shared/components/Tabs/Tabs.css +++ b/apps/extension/src/shared/components/Tabs/Tabs.css @@ -6,41 +6,16 @@ padding: 0; } .flowforge-tabs__tab { - appearance: none; - border: 0; border-bottom: 2px solid transparent; margin: 0 0 -1px; padding: 0 0 var(--flowforge-pad-xs) 0; - cursor: pointer; - background: transparent; - transition: var(--flowforge-transition-default); -} -.flowforge-tabs__tab-label { - display: inline-flex; - align-items: center; - gap: var(--flowforge-pad-xs); - padding: var(--flowforge-pad-xs) var(--flowforge-pad-sm); - border-radius: var(--flowforge-pad-xs); - color: var(--flowforge-color-text-muted); - font: var(--flowforge-text-sm); -} -.flowforge-tabs__tab:hover .flowforge-tabs__tab-label { - color: var(--flowforge-color-text); - background: var(--flowforge-color-surface); } .flowforge-tabs__tab--active { border-bottom-color: var(--flowforge-color-primary); } -.flowforge-tabs__tab--active .flowforge-tabs__tab-label { - color: var(--flowforge-color-text); +.flowforge-tabs__tab--disabled { + border-bottom-color: transparent; } -.flowforge-tabs__tab:focus-visible { - outline: none; -} -.flowforge-tabs__tab:focus-visible .flowforge-tabs__tab-label { - outline: 2px solid rgba(var(--flowforge-rgb-primary), 0.45); -} -.flowforge-tabs__tab:disabled { - cursor: not-allowed; - opacity: 0.5; +.flowforge-tabs__tab--active .flowforge-tabs__button { + color: var(--flowforge-color-text); } diff --git a/apps/extension/src/shared/components/Tabs/Tabs.tsx b/apps/extension/src/shared/components/Tabs/Tabs.tsx index d1b67f1..e1838ef 100644 --- a/apps/extension/src/shared/components/Tabs/Tabs.tsx +++ b/apps/extension/src/shared/components/Tabs/Tabs.tsx @@ -1,16 +1,71 @@ import type { ComponentChildren } from 'preact'; +import { forwardRef } from 'preact/compat'; import { useEffect, useId, useRef } from 'preact/hooks'; import type { LucideIcon } from 'lucide-preact'; -import { Icon } from '@/shared/components/Icon'; +import { Button } from '@/shared/components/Button'; +import { Tooltip } from '@/shared/components/Tooltip'; + +interface TabButtonProps { + tab: TabItem; + tabId: string; + panelId?: string; + active: boolean; + onSelect: () => void; +} + +const TabButton = forwardRef(function TabButton( + { tab, tabId, panelId, active, onSelect }, + ref, +) { + const classes = [ + 'flowforge-tabs__tab', + active && 'flowforge-tabs__tab--active', + tab.disabled && 'flowforge-tabs__tab--disabled', + ] + .filter(Boolean) + .join(' '); + + const button = ( + + ); + + return ( +
+ {tab.tooltip ? ( + + {button} + + ) : ( + button + )} +
+ ); +}); export interface TabItem { id: string; label: ComponentChildren; icon?: LucideIcon; disabled?: boolean; + tooltip?: ComponentChildren; } -interface TabsProps { +export interface TabsProps { tabs: readonly TabItem[]; activeId: string; onChange: (id: string) => void; @@ -69,26 +124,17 @@ export function Tabs({ tabs, activeId, onChange, autoFocus = false, getTabId, ge const isActive = index === activeIndex; return ( - + tab={tab} + tabId={resolveTabId(tab.id)} + panelId={getPanelId?.(tab.id)} + active={isActive} + onSelect={() => onChange(tab.id)} + /> ); })} diff --git a/apps/extension/src/shared/components/Tooltip/Tooltip.css b/apps/extension/src/shared/components/Tooltip/Tooltip.css new file mode 100644 index 0000000..8de4a73 --- /dev/null +++ b/apps/extension/src/shared/components/Tooltip/Tooltip.css @@ -0,0 +1,34 @@ +/* Tooltip */ +.flowforge-tooltip { + position: relative; + display: inline-flex; + align-items: center; +} +.flowforge-tooltip__content { + position: fixed; + z-index: 10; + width: max-content; + max-width: 220px; + box-sizing: border-box; + padding: var(--flowforge-pad-xs) var(--flowforge-pad-sm); + border-radius: var(--flowforge-pad-xs); + font: var(--flowforge-text-xs); + white-space: normal; + text-align: left; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: var(--flowforge-transition-default); +} +.flowforge-tooltip--primary .flowforge-tooltip__content { + color: var(--flowforge-color-text-on-dark); + background: var(--flowforge-color-primary); +} +.flowforge-tooltip--secondary .flowforge-tooltip__content { + color: var(--flowforge-color-text-on-dark); + background: var(--flowforge-color-secondary); +} +.flowforge-tooltip[data-open='true'] .flowforge-tooltip__content { + opacity: 1; + visibility: visible; +} diff --git a/apps/extension/src/shared/components/Tooltip/Tooltip.tsx b/apps/extension/src/shared/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..3e642a7 --- /dev/null +++ b/apps/extension/src/shared/components/Tooltip/Tooltip.tsx @@ -0,0 +1,213 @@ +import { cloneElement, toChildArray } from 'preact'; +import type { ComponentChildren, VNode } from 'preact'; +import { useEffect, useId, useRef, useState } from 'preact/hooks'; + +// constants +const TOOLTIP_POINTER_OPEN_DELAY_MS = 800; +const TOOLTIP_KEYBOARD_FOCUS_WINDOW_MS = 200; + +type TooltipSide = 'top' | 'bottom' | 'left' | 'right'; + +type TooltipProps = { + content: ComponentChildren; + children: ComponentChildren; + side?: TooltipSide; + disabled?: boolean; + variant?: 'primary' | 'secondary'; +}; + +const keyboardTooltipKeys = new Set(['Tab', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'Home', 'End']); + +// Checks whether a value is a single element that can be cloned. +function isVNode(value: unknown): value is VNode> { + return typeof value === 'object' && value !== null && 'type' in value && 'props' in value; +} + +// Detects keyboard-visible focus on the trigger or one of its descendants. +function hasVisibleFocus(el: HTMLElement): boolean { + return el.matches(':focus-visible') || el.querySelector(':focus-visible') !== null; +} + +// Calculates a viewport-clamped fixed position for the tooltip bubble. +function getTooltipPosition( + triggerRect: DOMRect, + tooltipRect: DOMRect, + side: TooltipSide, +): { top: number; left: number } { + const gap = 6; + const viewportPadding = 8; + const maxLeft = Math.max(viewportPadding, window.innerWidth - tooltipRect.width - viewportPadding); + const maxTop = Math.max(viewportPadding, window.innerHeight - tooltipRect.height - viewportPadding); + const clampLeft = (value: number) => Math.min(Math.max(value, viewportPadding), maxLeft); + const clampTop = (value: number) => Math.min(Math.max(value, viewportPadding), maxTop); + const centeredLeft = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + const centeredTop = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; + const preferredTop = triggerRect.top - tooltipRect.height - gap; + const preferredBottom = triggerRect.bottom + gap; + const preferredLeft = triggerRect.left - tooltipRect.width - gap; + const preferredRight = triggerRect.right + gap; + + if (side === 'top') { + return { + top: preferredTop >= viewportPadding ? preferredTop : clampTop(preferredBottom), + left: clampLeft(centeredLeft), + }; + } + if (side === 'bottom') { + return { + top: preferredBottom <= maxTop ? preferredBottom : clampTop(preferredTop), + left: clampLeft(centeredLeft), + }; + } + if (side === 'left') { + return { + top: clampTop(centeredTop), + left: preferredLeft >= viewportPadding ? preferredLeft : clampLeft(preferredRight), + }; + } + return { + top: clampTop(centeredTop), + left: preferredRight <= maxLeft ? preferredRight : clampLeft(preferredLeft), + }; +} + +// Renders a delayed, viewport-positioned tooltip around a trigger element. +export function Tooltip({ variant = 'primary', side = 'top', disabled = false, content, children }: TooltipProps) { + let lastKeyboardTooltipIntentAt = 0; + const id = `flowforge-tooltip-${useId()}`; + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const pointerOpenTimerRef = useRef(); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number }>(); + const childItems = toChildArray(children); + const onlyChild = childItems.length === 1 ? childItems[0] : undefined; + const classes = [ + 'flowforge-tooltip', + `flowforge-tooltip--${variant}`, + `flowforge-tooltip--${side}`, + disabled && 'flowforge-tooltip--disabled', + ] + .filter(Boolean) + .join(' '); + + const triggerProps = isVNode(onlyChild) ? (onlyChild.props as Record) : undefined; + const existingDescribedBy = + typeof triggerProps?.['aria-describedby'] === 'string' ? triggerProps['aria-describedby'] : undefined; + const trigger = + !disabled && isVNode(onlyChild) + ? cloneElement(onlyChild, { + 'aria-describedby': [existingDescribedBy, id].filter(Boolean).join(' '), + }) + : children; + + // Updates the tooltip bubble coordinates from the current trigger bounds. + const updatePosition = () => { + const wrapper = wrapperRef.current; + const tooltip = contentRef.current; + if (!wrapper || !tooltip) return; + + setPosition(getTooltipPosition(wrapper.getBoundingClientRect(), tooltip.getBoundingClientRect(), side)); + }; + + // Clears any pending delayed pointer-open timer. + const clearPointerOpenTimer = () => { + if (pointerOpenTimerRef.current === undefined) return; + + window.clearTimeout(pointerOpenTimerRef.current); + pointerOpenTimerRef.current = undefined; + }; + + // Starts the delayed hover-open timer for pointer users. + const schedulePointerOpen = () => { + if (disabled) return; + + clearPointerOpenTimer(); + pointerOpenTimerRef.current = window.setTimeout(() => { + pointerOpenTimerRef.current = undefined; + setOpen(true); + }, TOOLTIP_POINTER_OPEN_DELAY_MS); + }; + + // Closes the tooltip and cancels pending hover-open work. + const closeTooltip = () => { + clearPointerOpenTimer(); + setOpen(false); + }; + + // Opens the tooltip for recent keyboard-driven focus only. + const openTooltipOnKeyboardFocus = () => { + if (disabled) return; + + window.requestAnimationFrame(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !hasVisibleFocus(wrapper)) return; + if (Date.now() - lastKeyboardTooltipIntentAt > TOOLTIP_KEYBOARD_FOCUS_WINDOW_MS) return; + + setOpen(true); + }); + }; + + // Keeps the fixed tooltip aligned while the viewport or scroll position changes. + useEffect(() => { + if (!open) return; + + updatePosition(); + const handlePositionChange = () => { + updatePosition(); + }; + window.addEventListener('resize', handlePositionChange); + document.addEventListener('scroll', handlePositionChange, true); + return () => { + window.removeEventListener('resize', handlePositionChange); + document.removeEventListener('scroll', handlePositionChange, true); + }; + }, [open, side, content]); + + // Clears pending pointer timers when the tooltip unmounts. + useEffect(() => { + return () => { + clearPointerOpenTimer(); + }; + }, []); + + // Records recent keyboard navigation so programmatic focus does not open the tooltip. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!keyboardTooltipKeys.has(e.key)) return; + + lastKeyboardTooltipIntentAt = Date.now(); + }; + document.addEventListener('keydown', handleKeyDown, true); + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, []); + + return ( + + {trigger} + {!disabled && ( + + {content} + + )} + + ); +} diff --git a/apps/extension/src/shared/components/Tooltip/index.ts b/apps/extension/src/shared/components/Tooltip/index.ts new file mode 100644 index 0000000..b44d466 --- /dev/null +++ b/apps/extension/src/shared/components/Tooltip/index.ts @@ -0,0 +1 @@ +export { Tooltip } from './Tooltip'; diff --git a/apps/extension/src/shared/hooks/useSettings.ts b/apps/extension/src/shared/hooks/useSettings.ts index f538946..2afd8ed 100644 --- a/apps/extension/src/shared/hooks/useSettings.ts +++ b/apps/extension/src/shared/hooks/useSettings.ts @@ -2,12 +2,13 @@ import { useCallback, useEffect, useState } from 'preact/hooks'; import type { TransportService } from '@/adapters/interface'; import { type ExtensionSettings, - type ExtensionSettingsTheme, type GetSettingsMessage, type GetSettingsMessageResponse, - isApplySettingsMessage, type UpdateSettingsMessage, type UpdateSettingsMessageResponse, + isSettingsUpdatedMessage, + type Message, + type MessageResponse, } from '@/types'; import { config } from '@/config'; @@ -15,53 +16,67 @@ interface UseSettingsParams { transport: TransportService; } -export interface SettingsViewModel extends ExtensionSettings { +export interface SettingsReadyViewModel extends ExtensionSettings { + status: 'ready'; toggleTheme: () => Promise; + setDevMode: (enabled: boolean) => Promise; } +export interface SettingsLoadingViewModel { + status: 'loading'; +} + +export type SettingsViewModel = SettingsLoadingViewModel | SettingsReadyViewModel; + export function useSettings({ transport }: UseSettingsParams): SettingsViewModel { - const [settings, setSettings] = useState(config.defaultSettings); + const [settings, setSettings] = useState(null); // Get settings on mount useEffect(() => { void (async () => { - const response = await transport.sendToBackground({ - type: 'GET_SETTINGS', - }); - if (response.success) { - setSettings(response.data); + try { + const response = await transport.sendToBackground({ + type: 'GET_SETTINGS', + }); + setSettings(response.success ? response.data : config.defaultSettings); + } catch { + setSettings(config.defaultSettings); } })(); }, [transport]); - // Handle theme toggle - const handleToggleTheme = useCallback(async () => { - const nextTheme: ExtensionSettingsTheme = settings.theme === 'dark' ? 'light' : 'dark'; - const response = await transport.sendToBackground({ - type: 'UPDATE_SETTINGS', - senderId: await transport.getActiveSenderId(), - data: { - patch: { theme: nextTheme }, - }, - }); - if (response.success) { - setSettings(response.data); - } - }, [settings, transport]); - - // Listen to settings updates from background useEffect(() => { - return transport.addMessageListener((message) => { - if (isApplySettingsMessage(message)) { - setSettings(message.data.settings); + return transport.addMessageListener((message: Message): MessageResponse | undefined => { + if (isSettingsUpdatedMessage(message)) { + setSettings(message.data); return { success: true }; } return undefined; }); }, [transport]); + const handleUpdateSettings = useCallback( + async (patch: Partial) => { + const senderId = await transport.getActiveSenderId().catch(() => undefined); + const response = await transport.sendToBackground({ + type: 'UPDATE_SETTINGS', + senderId, + data: { patch }, + }); + if (response.success) { + setSettings(response.data); + } + }, + [transport], + ); + + if (!settings) { + return { status: 'loading' }; + } return { + status: 'ready', ...settings, - toggleTheme: handleToggleTheme, + toggleTheme: () => handleUpdateSettings({ theme: settings.theme === 'dark' ? 'light' : 'dark' }), + setDevMode: (enabled: boolean) => handleUpdateSettings({ devMode: enabled }), }; } diff --git a/apps/extension/src/shared/img/footer-bg-sm.png b/apps/extension/src/shared/img/footer-bg-sm.png new file mode 100644 index 0000000..40a93b5 Binary files /dev/null and b/apps/extension/src/shared/img/footer-bg-sm.png differ diff --git a/apps/extension/src/shared/img/footer-bg-sm.webp b/apps/extension/src/shared/img/footer-bg-sm.webp new file mode 100644 index 0000000..e0cbf7b Binary files /dev/null and b/apps/extension/src/shared/img/footer-bg-sm.webp differ diff --git a/apps/extension/src/shared/index.css b/apps/extension/src/shared/index.css index 727bf89..3861a38 100644 --- a/apps/extension/src/shared/index.css +++ b/apps/extension/src/shared/index.css @@ -5,10 +5,12 @@ @import './components/Link/Link.css'; @import './components/Button/Button.css'; @import './components/Button/ButtonText.css'; +@import './components/Switch/Switch.css'; @import './components/Icon/Icon.css'; @import './components/Card/Card.css'; @import './components/Notice/Notice.css'; @import './components/Star/StarTwinkle.css'; @import './components/Tabs/Tabs.css'; +@import './components/Tooltip/Tooltip.css'; @import './components/JsonViewer/JsonViewer.css'; @import './components/MarkdownViewer/MarkdownViewer.css'; diff --git a/apps/extension/src/shared/styles/tokens.css b/apps/extension/src/shared/styles/tokens.css index 738fe10..f260020 100644 --- a/apps/extension/src/shared/styles/tokens.css +++ b/apps/extension/src/shared/styles/tokens.css @@ -72,9 +72,9 @@ --flowforge-pad-lg: 12px; --flowforge-pad-xl: 16px; - --flowforge-icon-size-sm: 14px; - --flowforge-icon-size-md: 16px; - --flowforge-icon-size-lg: 18px; + --flowforge-icon-size-sm: 16px; + --flowforge-icon-size-md: 18px; + --flowforge-icon-size-lg: 20px; --flowforge-app-offset: 20px; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8fbb929..692b45b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,7 +40,7 @@ Inference layer supporting Ollama or OpenAI models for embeddings and generation 2. Extension sends `pageTrail + question` to backend (`POST /query`) 3. Backend indexes the submitted page snapshot 4. Agent executes with access to tools and vector search -5. Backend returns structured result (answer + target elements) +5. Backend returns `QueryResponse` with `result` and execution metadata 6. Browser runtime highlights elements and displays the response ### Indexing flow @@ -80,13 +80,16 @@ Shared request/response contracts live in `@flowforge/contract`; DOM snapshots l ## Contracts -Extension ↔ backend: +Browser runtime ↔ backend: - `POST /query` — submit user question with page data -- `POST /search` — semantic search over indexed content -- `GET /analytics` / `GET /health` — analytics and service status +- `POST /search` — semantic search over an already indexed page URL +- `GET /health` — service status +- `GET /analytics` — in-memory query analytics -`POST /query` accepts `question`, `pageTrail`, `domain`, and optional question history. It returns an `AgentResult` with `answer`, `mode`, optional `topic`, and target `elements`. +`POST /query` accepts `question`, `pageTrail`, `domain`, and optional `userContext.previousQuestions`. It indexes the submitted page before agent execution and returns `{ result, metadata }`. + +`result` is an `AgentResult` with `answer`, `mode`, optional `topic`, and target `elements`. `metadata` includes model, token usage, and execution time. `POST /search` accepts `pageUrl`, `query`, and optional `k`, then returns retrieved documents. Agent tools use structured Zod schemas. Indexer documents are stored per page URL and embedding provider with content text and source element metadata. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 96bb195..1f64ffa 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2,20 +2,22 @@ - Enhance logging (with pino, LangSmith, opentelemetry) - Enhance error handling and API validation +- Improve error handling for element lookup, backend availability, and unavailable page runtime - Test coverage (unit/integration, vitest, playwright, promptfoo) -- Create extendable abstraction over extractors, embeddings, tooling +- Create extendable abstraction over extractors, selectors, embeddings, tooling ## Page Trail - Cache for the collector heavy computes -- Adjust importance score for headings (h1-h4) - Parse structured data (Schema.org, og, x-card, etc.) for basics info +- Enhance dataId and CSS selector usage for Element locator ## Backend ### Models - Support Anthropic, Ollama Cloud providers +- Research OpenRouter, Lighter LM, and Cerebras.ai for LLM routing and low-latency inference - Research WebLLM in browser: instant summary, intent classification, local reranking, etc. - Try open source models: gpt-oss:120b, nemotron-3-super @@ -31,6 +33,7 @@ ### Reasoning +- Research OpenAI Agents SDK and Agna framework for agent orchestration - Use user context and navigation history for prompting - Present a page/website UI and meanings graph for reasoning @@ -49,6 +52,10 @@ - Add metrics: tokens usage, latency, errors, etc. - Refactor the Server, consider using Fastify instead of Express +### Browser automation + +- Research Stagehand for browser action planning and execution + ### Analytics - Save steps for workflows for more details @@ -57,8 +64,10 @@ ## Extension -- Support standalone mode, properly -- Enhance dataId and CSS selector usage for Element locator +- Support service pages where browser APIs allow content scripts or fallback flows +- Restore popup/page UI state when reopening the extension +- Style the wizard to better fit the host website +- Consider to separate Inspector ### Page inspector @@ -69,5 +78,6 @@ ## DX - Dev/Prod mode +- Publish PageTrail as a standalone package - Update backend/quick-setup.js - Generate CHANGELOG.md diff --git a/docs/DOM-TO-RAG-PIPELINE.md b/docs/DOM-TO-RAG-PIPELINE.md index f04489b..c05b995 100644 --- a/docs/DOM-TO-RAG-PIPELINE.md +++ b/docs/DOM-TO-RAG-PIPELINE.md @@ -28,9 +28,7 @@ Elements include: - Attributes and properties - Semantic roles and labels -- Embedded layout and context: - - Section (e.g., header, main, form) - - Hierarchical path within the page +- Embedded layout and context, including section and ancestor path - Stable `dataId` and optional CSS selector for browser-side lookup Each element is assigned an importance score. This layer defines _what exists on the page and how it is structured_. @@ -73,15 +71,15 @@ Retrieved documents are rescored with semantic similarity and UI-specific signal A hybrid scoring function is applied by the agent tools: ``` -score = semantic similarity + UI importance signals +score = semanticScore * weight + importanceScore * weight ``` Where: -- `semantic_score` reflects how well the document matches the query -- `importance_score` reflects UI relevance such as visibility, role, position, and interaction potential +- `semanticScore` reflects how well the document matches the query +- `importanceScore` reflects UI relevance such as visibility, role, position, and interaction potential -Different tools use different scoring profiles for lookup, answer, and action-oriented requests. +Lookup, answer, and action-oriented tools use different weights. ### 5. Resolution to tool results diff --git a/docs/assets/dom-rag-pipeline.png b/docs/assets/dom-rag-pipeline.png index 3fbc478..bf9fc9a 100644 Binary files a/docs/assets/dom-rag-pipeline.png and b/docs/assets/dom-rag-pipeline.png differ diff --git a/docs/assets/dom-rag-pipeline.webp b/docs/assets/dom-rag-pipeline.webp index a4df1a7..3fa5e81 100644 Binary files a/docs/assets/dom-rag-pipeline.webp and b/docs/assets/dom-rag-pipeline.webp differ diff --git a/docs/assets/footer-bg.png b/docs/assets/footer-bg.png new file mode 100644 index 0000000..c17d6b6 Binary files /dev/null and b/docs/assets/footer-bg.png differ diff --git a/packages/contract/src/types/agentResult.ts b/packages/contract/src/types/agentResult.ts index 26276b1..7cb15a5 100644 --- a/packages/contract/src/types/agentResult.ts +++ b/packages/contract/src/types/agentResult.ts @@ -5,7 +5,7 @@ export const AgentResultElementActionSchema = z.enum(['click', 'navigate', 'inpu export const AgentResultElementSchema = z.object({ text: z.string(), dataId: z.string(), - cssSelector: z.string(), + cssSelector: z.string().optional(), action: AgentResultElementActionSchema, }); diff --git a/packages/page-trail/README.md b/packages/page-trail/README.md index 2dbd2f5..45ecb3a 100644 --- a/packages/page-trail/README.md +++ b/packages/page-trail/README.md @@ -1,78 +1,97 @@ # PageTrail -Normalized DOM snapshot used as the core input for RAG, search, and UI guidance. +## Overview -## Structure +`@flowforge/page-trail` extracts the current document into a typed `PageTrail`. +The model is DOM-focused, LLM-independent, and keeps locator data for resolving +results back to browser elements. + +## Format + +`PageTrail` is the top-level snapshot object: ```ts interface PageTrail { basics: PageBasics; + structure: ContainerTreeNode[]; content: ContentElement[]; interactive: InteractiveElement[]; metadata: CollectionMetadata; } ``` -## Content elements +`basics` stores page metadata and viewport; `metadata` stores counts, limit +flags, timestamp, and per-stage timings. -Extracted from visible headings, paragraphs, list items, blockquotes, and figcaptions. Very short text is skipped. Default limit: 250. +## Structure Elements -- text -- type: heading | text -- selector, dataId, bbox -- context (path + optional sectionName) -- importanceScore [0..1] +Container elements are visible semantic wrappers: dialogs, forms, navigation, +landmarks, sections, widgets, and tables. They are collected as a DOM-ordered +tree and are not importance-scored or limited. -## Interactive elements +Each container includes `kind`, `type`, `role`, labels, source `tag`, `dataId`, +fallback `cssSelector`, `bbox`, and `meaningScore`. -Extracted from visible buttons, links, inputs, textarea/select/summary, and supported ARIA roles. Sensitive inputs are skipped. Default limit: 150. +## Content Elements -- role → type (button | input | select | link) -- text + labels -- state (disabled, checked, etc) -- link (if any) -- selector, dataId, bbox -- inViewport, aboveTheFold -- context -- importanceScore [0..1] +Content elements are visible headings, paragraphs, list items, blockquotes, and +figcaptions. Text shorter than five characters is skipped. The default retained +limit is 250 elements after scoring. -## Context +Each content record includes source text, `tag`, `dataId`, fallback +`cssSelector`, `bbox`, container `context`, `meaningScore`, and +`importanceScore`. -Derived from ancestor containers such as main content, navigation, footer, dialog, form, section, and table. Optional `sectionName` is resolved from aria labels, headings, or legends. +## Interactive Elements -## Importance +Interactive elements are visible buttons, links, inputs, textareas, selects, +summaries, dialogs, options, and supported ARIA controls. Sensitive fields are +excluded. The default retained limit is 150 elements after scoring. -Heuristic scoring normalized to [0..1]. It ranks elements, applies top-N limits, and improves retrieval quality. +Each interactive record includes `role`, text, labels, state, visibility, +optional link metadata, `dataId`, fallback `cssSelector`, `bbox`, context, and +scores. -## Metadata +## Context -`basics` stores URL, title, description, language, and viewport. `metadata` stores selected and total element counts, limit flags, `collectedAt`, and `durationMs`. +Content and interactive targets store a container path from the nearest ancestor +toward the page root. Path nodes include the container, distance, and +`relevanceScore`; breadcrumb indexes identify the strongest context entries. -## Usage +## Scoring -```ts -const pt = PageTrailCollector.collectFor(window, document, { - getElementDataId: (el) => getOrCreateDataId(el), -}); -``` +Scores are normalized to `[0..1]`: + +- `meaningScore` describes an element by itself. Containers use type, role, + labels, and size; content uses type and text length; interactive elements use + type, role, name, usability, required state, and size. +- `relevanceScore` scores one container for one target from container meaning, + distance, and target/container fit. +- `contextScore` aggregates the strongest relevant containers on a target path + and stores breadcrumb indexes for semantic context. +- `importanceScore` ranks content and interactive targets from `meaningScore` + plus a context boost; context cannot make a meaningless target important. ## Format -Helpers convert PageTrail data into semantic strings. +Semantic helpers are exported from the package root: ```ts -formatContentElement(el); -formatInteractiveElement(el); -formatContentElementShort(el); -formatInteractiveElementShort(el); -formatSampleHeadings(el, limit); -formatSampleInteractions(el, limit); -generateSemanticMarkdown(pageTrail); +semContentElement(contentElement).text(); +semInteractiveElement(interactiveElement).text(); +semContainerElement(containerElement).text(); +semSampleStructure(pageTrail.structure); +semMarkdown(pageTrail); ``` -## Notes +## Usage + +```ts +import { PageTrailCollector, semMarkdown } from '@flowforge/page-trail'; -- DOM → structured model (LLM-independent) -- Single-page snapshot -- Optimized for search and UI actions -- Safe by default (sensitive fields excluded) +const pageTrail = PageTrailCollector.collectFor(window, document, { + getElementDataId: (el) => getOrCreateDataId(el), +}); + +const preview = semMarkdown(pageTrail); +``` diff --git a/packages/page-trail/src/collector/ElementRegistry.test.ts b/packages/page-trail/src/collector/ElementRegistry.test.ts new file mode 100644 index 0000000..3e526e6 --- /dev/null +++ b/packages/page-trail/src/collector/ElementRegistry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ElementRegistry } from './ElementRegistry'; + +describe('ElementRegistry', () => { + it('resolves registered elements by data ID and data IDs by element', () => { + const registry = new ElementRegistry((el) => el.id); + const element = document.createElement('section'); + element.id = 'section'; + + const dataId = registry.register(element); + + expect(dataId).toBe('section'); + expect(registry.get('section')).toBe(element); + }); + + it('returns an existing data ID when registering the same element again', () => { + const produceDataId = vi.fn((el: Element) => el.id); + const registry = new ElementRegistry(produceDataId); + const element = document.createElement('section'); + element.id = 'section'; + + expect(registry.register(element)).toBe('section'); + expect(registry.register(element)).toBe('section'); + expect(produceDataId).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/page-trail/src/collector/ElementRegistry.ts b/packages/page-trail/src/collector/ElementRegistry.ts new file mode 100644 index 0000000..80141a3 --- /dev/null +++ b/packages/page-trail/src/collector/ElementRegistry.ts @@ -0,0 +1,23 @@ +import type { ElementDataId } from '../types/index.ts'; + +export class ElementRegistry { + private readonly elementByDataId = new Map(); + private dataIdByElement = new WeakMap(); + + constructor(private readonly produceDataId: (el: Element) => ElementDataId) {} + + register(el: Element): ElementDataId { + const existingDataId = this.dataIdByElement.get(el); + if (existingDataId) return existingDataId; + + const dataId = this.produceDataId(el); + this.elementByDataId.set(dataId, el); + this.dataIdByElement.set(el, dataId); + + return dataId; + } + + get(dataId: ElementDataId): Element | undefined { + return this.elementByDataId.get(dataId); + } +} diff --git a/packages/page-trail/src/collector/PageTrailCollector.test.ts b/packages/page-trail/src/collector/PageTrailCollector.test.ts index 7be8e12..aebe033 100644 --- a/packages/page-trail/src/collector/PageTrailCollector.test.ts +++ b/packages/page-trail/src/collector/PageTrailCollector.test.ts @@ -31,7 +31,7 @@ describe('PageTrailCollector', () => { }, }); expect(model.metadata.collectedAt).toBeTypeOf('number'); - expect(model.metadata.durationMs).toBeTypeOf('number'); + expect(model.metadata.performance.totalMs).toBeTypeOf('number'); }); it('normalizes page basics text', () => { @@ -72,7 +72,7 @@ describe('PageTrailCollector', () => { tag: 'h1', text: 'Welcome', dataId: 'title', - cssSelector: '#title', + cssSelector: undefined, }), expect.objectContaining({ kind: 'content', @@ -80,7 +80,7 @@ describe('PageTrailCollector', () => { tag: 'p', text: 'Useful paragraph text', dataId: 'intro', - cssSelector: '#intro', + cssSelector: undefined, }), ]), ); @@ -108,7 +108,7 @@ describe('PageTrailCollector', () => { type: 'button', role: 'button', dataId: 'save', - cssSelector: '#save', + cssSelector: undefined, labels: [{ source: 'aria-label', value: 'Save changes' }], }), expect.objectContaining({ @@ -116,7 +116,7 @@ describe('PageTrailCollector', () => { type: 'link', role: 'link', dataId: 'docs', - cssSelector: '#docs', + cssSelector: undefined, link: { type: 'internal', href: 'http://localhost:3000/docs', @@ -127,7 +127,7 @@ describe('PageTrailCollector', () => { type: 'input', role: 'textbox', dataId: 'email', - cssSelector: '#email', + cssSelector: undefined, labels: [{ source: 'placeholder', value: 'Email' }], }), ]), @@ -174,6 +174,24 @@ describe('PageTrailCollector', () => { expect(model.interactive[0]).toEqual(expect.objectContaining({ dataId: 'button' })); }); + it('reports container metadata without scoring limit totals', () => { + document.body.innerHTML = ` +
+
+
+ `; + markVisible('#main'); + markVisible('#section'); + + const model = collect(); + + expect(model.metadata.structureElements).toBe(2); + expect(model.metadata.structureMaxDepth).toBe(2); + expect(model.metadata).not.toHaveProperty('containerElementsTotal'); + expect(model.metadata).not.toHaveProperty('containerElementsLimitReached'); + expect(model.structure[0]).not.toHaveProperty('importanceScore'); + }); + it('keeps cssSelector undefined when css selector resolver is not configured', () => { document.body.innerHTML = ``; markVisible('#save'); @@ -201,7 +219,6 @@ describe('PageTrailCollector', () => { function collect(options: Partial[2]> = {}) { return new PageTrailCollector(window, document, { getElementDataId: (el) => el.id, - getElementCssSelector: (el) => `#${el.id}`, ...options, }).collect(); } diff --git a/packages/page-trail/src/collector/PageTrailCollector.ts b/packages/page-trail/src/collector/PageTrailCollector.ts index 9a37d47..32e3dfd 100644 --- a/packages/page-trail/src/collector/PageTrailCollector.ts +++ b/packages/page-trail/src/collector/PageTrailCollector.ts @@ -1,83 +1,76 @@ -import type { ContentElement, ElementIdentifier, InteractiveElement, PageBasics, PageTrail } from '../types/index.ts'; +import type { ContentElement, InteractiveElement, PageBasics, PageTrail } from '../types/index.ts'; -import { getElementLabels } from './extractors/primitive/label.ts'; -import { getInteractiveRole, roleToInteractiveElementType } from './extractors/primitive/role.ts'; -import { getElementBoundingBox, isAboveTheFold, isElementVisible, isInViewport } from './extractors/primitive/view.ts'; -import { getInteractiveElementState } from './extractors/primitive/state.ts'; -import { getElementText } from './extractors/primitive/text.ts'; -import { getElementLink } from './extractors/primitive/link.ts'; -import { getElementContext } from './extractors/context.ts'; -import { isSensitiveElement } from './extractors/primitive/sensitive.ts'; -import { scoreContentElement, scoreInteractiveElement } from './importance/scoring.ts'; -import { type TopElements, topElements } from './importance/topEl.ts'; -import type { InteractiveElementScoringData } from './importance/interactive.ts'; -import type { ContentElementScoringData } from './importance/content.ts'; -import { normalizeText } from '../utils/index.ts'; - -// constants -const CONTENT_MIN_TEXT_LENGTH = 5; +import { type TopElements } from './scoring/topEl.ts'; +import { ContainerTree } from './extractors/index.ts'; +import { ElementRegistry } from './ElementRegistry.ts'; +import { extractContentElements } from './extractors/content.ts'; +import { extractPageBasics } from './extractors/basics.ts'; +import { extractInteractiveElements } from './extractors/index.ts'; export interface CollectorOptions { + /** Maximum number of content elements to keep after importance scoring. */ contentElementsLimit?: number; + /** Maximum number of interactive elements to keep after importance scoring. */ interactiveElementsLimit?: number; + /** Returns the stable identifier used to link extracted records back to DOM elements. */ getElementDataId: (el: Element) => string; - // For example: css-selector-generator - getElementCssSelector?: (el: Element) => string; } type ResolvedCollectorOptions = Required> & - Pick; + Pick; /** - * Collects a normalized PageTrail from the DOM + * Orchestrates PageTrail extraction for a document. * * TODO: implement a cache, but with dataId ref consistency * TODO: provide a plugins API to extend the collector * - * Extracts page metadata, content, and interactive elements, and assigns - * stable `dataId` identifiers to elements for downstream usage. + * The collector owns shared extraction state, delegates DOM scanning to + * specialized extractors, and combines their results into a normalized + * `PageTrail` with collection metadata. */ export class PageTrailCollector { - // window.location.href - reads the current page URL - // window.innerWidth - reads viewport width - // window.innerHeight - reads viewport height - // window.scrollY - reads current vertical scroll position - // window.getComputedStyle(element) - checks computed CSS styles for visibility private readonly window: Window; - // document.title - reads the page title - // document.querySelector('meta[name="description"]') - finds the meta description element - // document.documentElement.lang - reads the page language from - // document.documentElement.scrollHeight - reads the full scrollable page height - // document.querySelectorAll(selector) - finds content and interactive elements by CSS selector - // document.getElementById(id) - resolves IDs from aria-labelledby to label elements - // document.querySelector('label[for="..."]') - finds a