From a2b5e99c58e6ea28b4979034682268f153f08cd3 Mon Sep 17 00:00:00 2001
From: cerberus-ab
Date: Sun, 16 Aug 2026 23:43:41 +0200
Subject: [PATCH 01/15] Init ContainerTree
---
.../src/agent/tools/AbstractCallableTool.ts | 4 +-
.../AbstractDocumentTransformer.ts | 4 +-
apps/backend/src/types/documents.ts | 4 +-
apps/extension/src/core/constants.ts | 1 +
.../page/components/Inspector/Inspector.tsx | 6 +-
.../components/Metadata/PageMetadata.tsx | 2 +-
apps/extension/src/page/hooks/usePage.ts | 1 +
.../src/collector/ElementRegistry.ts | 21 ++
.../src/collector/PageTrailCollector.test.ts | 31 ++-
.../src/collector/PageTrailCollector.ts | 248 ++++--------------
.../{extractors => context}/context.test.ts | 0
.../{extractors => context}/context.ts | 18 +-
.../extractors/ContainerTree.test.ts | 195 ++++++++++++++
.../src/collector/extractors/ContainerTree.ts | 153 +++++++++++
.../src/collector/extractors/basics.test.ts | 30 +++
.../src/collector/extractors/basics.ts | 24 ++
.../src/collector/extractors/content.test.ts | 74 ++++++
.../src/collector/extractors/content.ts | 68 +++++
.../collector/extractors/interactive.test.ts | 103 ++++++++
.../src/collector/extractors/interactive.ts | 83 ++++++
.../extractors/primitive/heading.test.ts | 93 +++++++
.../collector/extractors/primitive/heading.ts | 27 ++
.../extractors/primitive/label.test.ts | 56 +++-
.../collector/extractors/primitive/label.ts | 88 +++++--
.../extractors/primitive/role.test.ts | 14 +-
.../collector/extractors/primitive/role.ts | 50 +++-
.../extractors/primitive/selector.ts | 7 +
.../src/collector/importance/interactive.ts | 4 +-
.../src/collector/selectors.test.ts | 173 ++++++++++++
.../page-trail/src/collector/selectors.ts | 14 +
packages/page-trail/src/semantic/format.ts | 4 +-
packages/page-trail/src/types/pageTrail.ts | 102 ++++---
32 files changed, 1418 insertions(+), 284 deletions(-)
create mode 100644 packages/page-trail/src/collector/ElementRegistry.ts
rename packages/page-trail/src/collector/{extractors => context}/context.test.ts (100%)
rename packages/page-trail/src/collector/{extractors => context}/context.ts (88%)
create mode 100644 packages/page-trail/src/collector/extractors/ContainerTree.test.ts
create mode 100644 packages/page-trail/src/collector/extractors/ContainerTree.ts
create mode 100644 packages/page-trail/src/collector/extractors/basics.test.ts
create mode 100644 packages/page-trail/src/collector/extractors/basics.ts
create mode 100644 packages/page-trail/src/collector/extractors/content.test.ts
create mode 100644 packages/page-trail/src/collector/extractors/content.ts
create mode 100644 packages/page-trail/src/collector/extractors/interactive.test.ts
create mode 100644 packages/page-trail/src/collector/extractors/interactive.ts
create mode 100644 packages/page-trail/src/collector/extractors/primitive/heading.test.ts
create mode 100644 packages/page-trail/src/collector/extractors/primitive/heading.ts
create mode 100644 packages/page-trail/src/collector/extractors/primitive/selector.ts
create mode 100644 packages/page-trail/src/collector/selectors.test.ts
create mode 100644 packages/page-trail/src/collector/selectors.ts
diff --git a/apps/backend/src/agent/tools/AbstractCallableTool.ts b/apps/backend/src/agent/tools/AbstractCallableTool.ts
index 4e45440..b71cbf6 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 { formantElementContextPath, type TargetElement } from '@flowforge/page-trail';
export abstract class AbstractCallableTool implements CallableTool {
readonly name: string;
@@ -33,7 +33,7 @@ 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 ?? '',
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/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/extension/src/core/constants.ts b/apps/extension/src/core/constants.ts
index 541279f..072ff2a 100644
--- a/apps/extension/src/core/constants.ts
+++ b/apps/extension/src/core/constants.ts
@@ -8,6 +8,7 @@ export const constants = {
EXAMPLES_MAX_ITEMS: 5,
EXAMPLES_MAX_QUESTION_LENGTH: 50,
// extractors
+ CONTAINER_ELEMENTS_LIMIT: 50,
CONTENT_ELEMENTS_LIMIT: 250,
INTERACTIVE_ELEMENTS_LIMIT: 150,
// highlighter
diff --git a/apps/extension/src/page/components/Inspector/Inspector.tsx b/apps/extension/src/page/components/Inspector/Inspector.tsx
index e0a7b41..e8ddc8f 100644
--- a/apps/extension/src/page/components/Inspector/Inspector.tsx
+++ b/apps/extension/src/page/components/Inspector/Inspector.tsx
@@ -1,6 +1,6 @@
import type { TargetedPointerEvent } from 'preact';
import { useEffect, useId, useState } from 'preact/hooks';
-import { BadgeInfo, BookOpenText, FileText, MousePointerClick } from 'lucide-preact';
+import { 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';
@@ -12,9 +12,11 @@ import { formatContentElement, formatInteractiveElement, generateSemanticMarkdow
const inspectorTabs = [
{ id: 'basics', label: 'Basics', icon: BadgeInfo },
+ { id: 'container', label: 'Container', icon: ListTree },
{ id: 'content', label: 'Content', icon: BookOpenText },
{ id: 'interactive', label: 'Interactive', icon: MousePointerClick },
{ id: 'semanticView', label: 'Semantic view', icon: FileText },
+ { id: 'metadata', label: 'Metadata', icon: ChartNoAxesColumn },
] as const;
type InspectorTabId = (typeof inspectorTabs)[number]['id'];
@@ -105,6 +107,7 @@ export function Inspector({ pageTrail, close }: InspectorViewModel) {
aria-labelledby={getTabId(activeTab)}
>
{activeTab === 'basics' && }
+ {activeTab === 'container' && }
{activeTab === 'content' && (
)}
{activeTab === 'semanticView' && }
+ {activeTab === 'metadata' && }
diff --git a/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx b/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx
index 85d7739..f699a51 100644
--- a/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx
+++ b/apps/extension/src/page/components/Inspector/components/Metadata/PageMetadata.tsx
@@ -35,7 +35,7 @@ export function PageMetadata({ metadata }: PageMetadataProps) {
) : (
<>{metadata.interactiveElements}>
)}{' '}
- interactive elements · {metadata.durationMs}ms
+ interactive elements · {metadata.performance.totalMs}ms
);
}
diff --git a/apps/extension/src/page/hooks/usePage.ts b/apps/extension/src/page/hooks/usePage.ts
index 314bb8e..6211b1c 100644
--- a/apps/extension/src/page/hooks/usePage.ts
+++ b/apps/extension/src/page/hooks/usePage.ts
@@ -15,6 +15,7 @@ import { PageTrailCollector } from '@flowforge/page-trail';
function collectPageTrail(): PageTrail {
return PageTrailCollector.collectFor(window, document, {
+ containerElementsLimit: constants.CONTAINER_ELEMENTS_LIMIT,
contentElementsLimit: constants.CONTENT_ELEMENTS_LIMIT,
interactiveElementsLimit: constants.INTERACTIVE_ELEMENTS_LIMIT,
getElementDataId: getOrCreateDataId,
diff --git a/packages/page-trail/src/collector/ElementRegistry.ts b/packages/page-trail/src/collector/ElementRegistry.ts
new file mode 100644
index 0000000..5a77608
--- /dev/null
+++ b/packages/page-trail/src/collector/ElementRegistry.ts
@@ -0,0 +1,21 @@
+import type { ElementDataId } from '../types/index.ts';
+
+export class ElementRegistry {
+ private readonly elementByDataId = new Map();
+
+ constructor(private readonly produceDataId: (el: Element) => ElementDataId) {}
+
+ register(el: Element): ElementDataId {
+ const dataId = this.produceDataId(el);
+ this.elementByDataId.set(dataId, el);
+ return dataId;
+ }
+
+ get(dataId: ElementDataId): Element | undefined {
+ return this.elementByDataId.get(dataId);
+ }
+
+ clear(): void {
+ this.elementByDataId.clear();
+ }
+}
diff --git a/packages/page-trail/src/collector/PageTrailCollector.test.ts b/packages/page-trail/src/collector/PageTrailCollector.test.ts
index 7be8e12..8192cd5 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.containerElements).toBe(2);
+ expect(model.metadata.containerMaxDepth).toBe(2);
+ expect(model.metadata).not.toHaveProperty('containerElementsTotal');
+ expect(model.metadata).not.toHaveProperty('containerElementsLimitReached');
+ expect(model.container[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..d36371a 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 './importance/topEl.ts';
+import { ContainerTree } from './extractors/ContainerTree.ts';
+import { ElementRegistry } from './ElementRegistry.ts';
+import { extractContentElements } from './extractors/content.ts';
+import { extractPageBasics } from './extractors/basics.ts';
+import { extractInteractiveElements } from './extractors/interactive.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
-
+
+
+
diff --git a/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx b/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx
index e08ad83..3554592 100644
--- a/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx
+++ b/apps/extension/src/page/components/Inspector/components/Metadata/InspectorPageMetadata.tsx
@@ -1,4 +1,7 @@
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
@@ -8,12 +11,9 @@ export function InspectorPageMetadata({ metadata }: { metadata: PageTrail['metad
Selected{' '}
{metadata.contentElementsLimitReached ? (
<>
-
- {metadata.contentElements}
-
+
+ {metadata.contentElements}
+
/{metadata.contentElementsTotal}
>
) : (
@@ -22,12 +22,9 @@ export function InspectorPageMetadata({ metadata }: { metadata: PageTrail['metad
content elements,{' '}
{metadata.interactiveElementsLimitReached ? (
<>
-
- {metadata.interactiveElements}
-
+
+ {metadata.interactiveElements}
+
/{metadata.interactiveElementsTotal}
>
) : (
diff --git a/apps/extension/src/popup/components/Understanding/Understanding.tsx b/apps/extension/src/popup/components/Understanding/Understanding.tsx
index 42aa84f..7bd4884 100644
--- a/apps/extension/src/popup/components/Understanding/Understanding.tsx
+++ b/apps/extension/src/popup/components/Understanding/Understanding.tsx
@@ -1,6 +1,7 @@
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;
@@ -12,26 +13,28 @@ export function Understanding({ onOpenPageInspector }: UnderstandingProps) {
-
-
+
+
+
-
-
+
+
+
diff --git a/apps/extension/src/shared/components/Tabs/Tabs.tsx b/apps/extension/src/shared/components/Tabs/Tabs.tsx
index 569ce36..e1838ef 100644
--- a/apps/extension/src/shared/components/Tabs/Tabs.tsx
+++ b/apps/extension/src/shared/components/Tabs/Tabs.tsx
@@ -3,22 +3,7 @@ import { forwardRef } from 'preact/compat';
import { useEffect, useId, useRef } from 'preact/hooks';
import type { LucideIcon } from 'lucide-preact';
import { Button } from '@/shared/components/Button';
-
-export interface TabItem {
- id: string;
- label: ComponentChildren;
- icon?: LucideIcon;
- disabled?: boolean;
-}
-
-interface TabsProps {
- tabs: readonly TabItem[];
- activeId: string;
- onChange: (id: string) => void;
- autoFocus?: boolean;
- getTabId?: (id: string) => string;
- getPanelId?: (id: string) => string;
-}
+import { Tooltip } from '@/shared/components/Tooltip';
interface TabButtonProps {
tab: TabItem;
@@ -40,28 +25,55 @@ const TabButton = forwardRef
(function TabButt
.filter(Boolean)
.join(' ');
+ const button = (
+
+ );
+
return (
-
+ {tab.tooltip ? (
+
+ {button}
+
+ ) : (
+ button
+ )}
);
});
+export interface TabItem {
+ id: string;
+ label: ComponentChildren;
+ icon?: LucideIcon;
+ disabled?: boolean;
+ tooltip?: ComponentChildren;
+}
+
+export interface TabsProps {
+ tabs: readonly TabItem[];
+ activeId: string;
+ onChange: (id: string) => void;
+ autoFocus?: boolean;
+ getTabId?: (id: string) => string;
+ getPanelId?: (id: string) => string;
+}
+
export function Tabs({ tabs, activeId, onChange, autoFocus = false, getTabId, getPanelId }: TabsProps) {
const fallbackIdPrefix = `flowforge-tabs-${useId()}`;
const tabRefs = useRef>({});
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/index.css b/apps/extension/src/shared/index.css
index 0a38599..3861a38 100644
--- a/apps/extension/src/shared/index.css
+++ b/apps/extension/src/shared/index.css
@@ -11,5 +11,6 @@
@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';
From ecc3a5e4f6e132b1a11e423de2b0e46c56726006 Mon Sep 17 00:00:00 2001
From: cerberus-ab
Date: Sun, 23 Aug 2026 01:12:15 +0200
Subject: [PATCH 13/15] Fix settings sync between popup/page
---
.../src/background/BackgroundWorker.ts | 7 +++
.../src/chrome/action/popup/popup.tsx | 11 ++--
.../src/chrome/contentScripts/page.tsx | 9 ++-
apps/extension/src/core/types/messages.ts | 18 +++++-
apps/extension/src/embed/Runtime.tsx | 8 ++-
apps/extension/src/embed/shell/ShellApp.tsx | 21 +++++--
apps/extension/src/page/PageApp.tsx | 5 +-
apps/extension/src/page/hooks/usePage.ts | 16 +++--
.../popup/components/Examples/Examples.css | 5 +-
.../src/shared/components/Button/Button.css | 6 +-
.../src/shared/components/Switch/Switch.css | 11 ++--
.../extension/src/shared/hooks/useSettings.ts | 58 ++++++++++++-------
12 files changed, 122 insertions(+), 53 deletions(-)
diff --git a/apps/extension/src/background/BackgroundWorker.ts b/apps/extension/src/background/BackgroundWorker.ts
index 53c28c1..41b1746 100644
--- a/apps/extension/src/background/BackgroundWorker.ts
+++ b/apps/extension/src/background/BackgroundWorker.ts
@@ -21,6 +21,7 @@ import {
type OpenInspectorMessage,
type OpenPageInspectorMessage,
type PopupInitializeMessage,
+ type SettingsUpdatedMessage,
type StartOnboardingMessage,
type UpdateSettingsMessage,
type UpdateSettingsMessageResponse,
@@ -124,6 +125,12 @@ export class BackgroundWorker {
private async handleUpdateSettings(message: UpdateSettingsMessage): Promise {
try {
const updatedSettings = await this.settingsStorage.update(message.data.patch);
+ 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);
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 c8972e0..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, devMode, setDevMode } = useSettings({ transport });
+ const settings = useSettings({ transport });
+ if (settings.status === 'loading') {
+ return null;
+ }
return (
-
-
+
+
);
}
diff --git a/apps/extension/src/core/types/messages.ts b/apps/extension/src/core/types/messages.ts
index 7e16629..ddd18df 100644
--- a/apps/extension/src/core/types/messages.ts
+++ b/apps/extension/src/core/types/messages.ts
@@ -7,7 +7,12 @@ type MessageTypePopupToBackground =
'POPUP_INITIALISE' | 'ASK_QUESTION' | 'GET_PREV_QUESTIONS' | 'NAVIGATE_TO_ELEMENT' | 'OPEN_PAGE_INSPECTOR';
type MessageTypeBackgroundToPage =
- 'COLLECT_PAGE_TRAIL' | 'START_ONBOARDING' | 'HIGHLIGHT_ELEMENT' | 'CLEAR_PAGE' | 'OPEN_INSPECTOR';
+ | 'COLLECT_PAGE_TRAIL'
+ | 'START_ONBOARDING'
+ | 'HIGHLIGHT_ELEMENT'
+ | 'CLEAR_PAGE'
+ | 'OPEN_INSPECTOR'
+ | 'SETTINGS_UPDATED';
type MessageType = MessageTypeToBackground | MessageTypePopupToBackground | MessageTypeBackgroundToPage;
@@ -32,6 +37,7 @@ export type UpdateSettingsMessageData = {
export type UpdateSettingsMessage = Message & {
type: 'UPDATE_SETTINGS';
+ senderId?: number;
};
export type UpdateSettingsMessageResponseData = ExtensionSettings;
@@ -122,6 +128,12 @@ 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 {
@@ -171,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/embed/Runtime.tsx b/apps/extension/src/embed/Runtime.tsx
index 3d937de..00c0c65 100644
--- a/apps/extension/src/embed/Runtime.tsx
+++ b/apps/extension/src/embed/Runtime.tsx
@@ -150,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);
@@ -159,6 +164,7 @@ export class Runtime implements RuntimeApi {
transport={this.transport}
triggerSize={options.triggerSize}
demoProps={options.demoProps}
+ onShellReady={resolveShellReady}
/>,
shellRoot.mountPoint,
);
@@ -176,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/shell/ShellApp.tsx b/apps/extension/src/embed/shell/ShellApp.tsx
index 814de6d..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, devMode, toggleTheme, setDevMode } = 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 d2504e0..79417ea 100644
--- a/apps/extension/src/page/PageApp.tsx
+++ b/apps/extension/src/page/PageApp.tsx
@@ -9,10 +9,11 @@ export interface PageAppProps {
transport: TransportService;
devMode: boolean;
onDevModeChange: (enabled: boolean) => void | Promise;
+ onReady?: () => void;
}
-export function PageApp({ transport, devMode, onDevModeChange }: PageAppProps) {
- const { highlights, wizard, inspector } = usePage({ transport, devMode, onDevModeChange });
+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/hooks/usePage.ts b/apps/extension/src/page/hooks/usePage.ts
index eba26b4..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';
@@ -25,6 +25,7 @@ export interface UsePageOptions {
transport: TransportService;
devMode: boolean;
onDevModeChange: (enabled: boolean) => void | Promise
;
+ onReady?: () => void;
}
interface HighlightState {
@@ -68,10 +69,11 @@ export interface PageViewModel {
inspector: InspectorViewModel | null;
}
-export function usePage({ transport, devMode, onDevModeChange }: 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([]);
@@ -170,7 +172,7 @@ export function usePage({ transport, devMode, onDevModeChange }: UsePageOptions)
// 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 };
@@ -194,7 +196,13 @@ export function usePage({ transport, devMode, onDevModeChange }: UsePageOptions)
}
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) => ({
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/shared/components/Button/Button.css b/apps/extension/src/shared/components/Button/Button.css
index 31cd297..78f613f 100644
--- a/apps/extension/src/shared/components/Button/Button.css
+++ b/apps/extension/src/shared/components/Button/Button.css
@@ -6,7 +6,7 @@
justify-content: center;
gap: var(--flowforge-pad-xs);
padding: var(--flowforge-pad-sm) var(--flowforge-pad-lg);
- border: none;
+ border: 1px solid transparent;
border-radius: var(--flowforge-pad-xs);
font: var(--flowforge-text-md);
cursor: pointer;
@@ -54,12 +54,12 @@
opacity: 0.5;
}
.flowforge-button--ghost {
- background: transparent;
+ background: var(--flowforge-color-surface);
color: var(--flowforge-color-text-muted);
}
.flowforge-button--ghost:not(:disabled):hover {
color: var(--flowforge-color-text);
- background: var(--flowforge-color-surface);
+ border-color: var(--flowforge-color-border);
}
.flowforge-button--ghost:focus-visible {
outline: 2px solid rgba(var(--flowforge-rgb-primary), 0.45);
diff --git a/apps/extension/src/shared/components/Switch/Switch.css b/apps/extension/src/shared/components/Switch/Switch.css
index 4fa9ef5..b2691a2 100644
--- a/apps/extension/src/shared/components/Switch/Switch.css
+++ b/apps/extension/src/shared/components/Switch/Switch.css
@@ -7,9 +7,9 @@
gap: var(--flowforge-pad-xs);
margin: 0;
padding: var(--flowforge-pad-xs) var(--flowforge-pad-sm);
- border: none;
+ border: 1px solid transparent;
border-radius: var(--flowforge-pad-xs);
- background: transparent;
+ background: var(--flowforge-color-surface);
color: var(--flowforge-color-text-muted);
font: var(--flowforge-text-xs);
cursor: pointer;
@@ -28,7 +28,7 @@
}
.flowforge-switch:not(:disabled):hover {
color: var(--flowforge-color-text);
- background: var(--flowforge-color-surface);
+ border-color: var(--flowforge-color-border);
}
.flowforge-switch:focus-visible {
outline: 2px solid rgba(var(--flowforge-rgb-primary), 0.45);
@@ -47,7 +47,10 @@
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
- transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
+ transition:
+ background 0.2s ease,
+ border-color 0.2s ease,
+ opacity 0.2s ease;
}
.flowforge-switch__thumb {
position: absolute;
diff --git a/apps/extension/src/shared/hooks/useSettings.ts b/apps/extension/src/shared/hooks/useSettings.ts
index 747b0f8..2afd8ed 100644
--- a/apps/extension/src/shared/hooks/useSettings.ts
+++ b/apps/extension/src/shared/hooks/useSettings.ts
@@ -6,6 +6,9 @@ import {
type GetSettingsMessageResponse,
type UpdateSettingsMessage,
type UpdateSettingsMessageResponse,
+ isSettingsUpdatedMessage,
+ type Message,
+ type MessageResponse,
} from '@/types';
import { config } from '@/config';
@@ -13,30 +16,51 @@ 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]);
+ useEffect(() => {
+ 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) {
@@ -46,21 +70,13 @@ export function useSettings({ transport }: UseSettingsParams): SettingsViewModel
[transport],
);
- // Handle theme toggle
- const handleToggleTheme = useCallback(
- () => handleUpdateSettings({ theme: settings.theme === 'dark' ? 'light' : 'dark' }),
- [handleUpdateSettings, settings.theme],
- );
-
- // Handle set dev mode
- const handleSetDevMode = useCallback(
- (enabled: boolean) => handleUpdateSettings({ devMode: enabled }),
- [handleUpdateSettings],
- );
-
+ if (!settings) {
+ return { status: 'loading' };
+ }
return {
+ status: 'ready',
...settings,
- toggleTheme: handleToggleTheme,
- setDevMode: handleSetDevMode,
+ toggleTheme: () => handleUpdateSettings({ theme: settings.theme === 'dark' ? 'light' : 'dark' }),
+ setDevMode: (enabled: boolean) => handleUpdateSettings({ devMode: enabled }),
};
}
From 2c4cefcb38f7035562ceba6602a087f581aa82c8 Mon Sep 17 00:00:00 2001
From: cerberus-ab
Date: Sun, 23 Aug 2026 13:39:25 +0200
Subject: [PATCH 14/15] Add limits on label/text primitives extraction
---
.../src/background/BackgroundWorker.ts | 10 +++--
apps/extension/src/popup/PopupApp.tsx | 11 +++--
.../src/collector/ElementRegistry.test.ts | 1 -
.../extractors/ContainerTree.test.ts | 22 ++++++++++
.../src/collector/extractors/ContainerTree.ts | 16 ++++++-
.../src/collector/extractors/content.ts | 2 +-
.../src/collector/extractors/interactive.ts | 7 ++-
.../extractors/primitive/label.test.ts | 30 +++++++++++++
.../collector/extractors/primitive/label.ts | 35 +++++++++------
.../extractors/primitive/text.test.ts | 11 ++++-
.../collector/extractors/primitive/text.ts | 11 +++--
.../collector/scoring/meaning/content.test.ts | 4 +-
.../src/collector/scoring/meaning/content.ts | 15 +++----
.../scoring/meaning/interactive.test.ts | 44 ++++++++++++++-----
.../collector/scoring/meaning/interactive.ts | 24 +++++++---
.../src/semantic/document/markdown.example.md | 2 +-
packages/page-trail/src/utils/text.test.ts | 8 ++++
packages/page-trail/src/utils/text.ts | 25 +++++++++--
18 files changed, 211 insertions(+), 67 deletions(-)
diff --git a/apps/extension/src/background/BackgroundWorker.ts b/apps/extension/src/background/BackgroundWorker.ts
index 41b1746..3d79313 100644
--- a/apps/extension/src/background/BackgroundWorker.ts
+++ b/apps/extension/src/background/BackgroundWorker.ts
@@ -126,10 +126,12 @@ export class BackgroundWorker {
try {
const updatedSettings = await this.settingsStorage.update(message.data.patch);
if (message.senderId !== undefined) {
- void this.transport.sendToPage(message.senderId, {
- type: 'SETTINGS_UPDATED',
- data: updatedSettings,
- }).catch(() => undefined);
+ void this.transport
+ .sendToPage(message.senderId, {
+ type: 'SETTINGS_UPDATED',
+ data: updatedSettings,
+ })
+ .catch(() => undefined);
}
return { success: true, data: updatedSettings };
} catch (error) {
diff --git a/apps/extension/src/popup/PopupApp.tsx b/apps/extension/src/popup/PopupApp.tsx
index 23f7ba7..5a2f13b 100644
--- a/apps/extension/src/popup/PopupApp.tsx
+++ b/apps/extension/src/popup/PopupApp.tsx
@@ -77,10 +77,13 @@ export function PopupApp({
);
// Handle open page inspector and close popup
- const handleOpenPageInspector = useCallback((tab?: string) => {
- openPageInspector(tab);
- onClose?.();
- }, [openPageInspector, onClose]);
+ const handleOpenPageInspector = useCallback(
+ (tab?: string) => {
+ openPageInspector(tab);
+ onClose?.();
+ },
+ [openPageInspector, onClose],
+ );
const isDialog = variant === 'dialog';
const Root = isDialog ? 'section' : 'div';
diff --git a/packages/page-trail/src/collector/ElementRegistry.test.ts b/packages/page-trail/src/collector/ElementRegistry.test.ts
index 484e436..3e526e6 100644
--- a/packages/page-trail/src/collector/ElementRegistry.test.ts
+++ b/packages/page-trail/src/collector/ElementRegistry.test.ts
@@ -24,5 +24,4 @@ describe('ElementRegistry', () => {
expect(registry.register(element)).toBe('section');
expect(produceDataId).toHaveBeenCalledOnce();
});
-
});
diff --git a/packages/page-trail/src/collector/extractors/ContainerTree.test.ts b/packages/page-trail/src/collector/extractors/ContainerTree.test.ts
index 99f0477..63dfa00 100644
--- a/packages/page-trail/src/collector/extractors/ContainerTree.test.ts
+++ b/packages/page-trail/src/collector/extractors/ContainerTree.test.ts
@@ -297,6 +297,28 @@ describe('ContainerTree', () => {
expect(path.map((node) => node.element.dataId)).toEqual(['article', 'section', 'main']);
});
+ it('keeps using the extracted container tree after finding the nearest path node', () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ markVisible('#main', containerRect);
+ markVisible('#section', containerRect);
+ markVisible('#article', containerRect);
+
+ const tree = createTree();
+ document.querySelector('#main')!.append(document.querySelector('#article')!);
+
+ const path = getPathToRoot(tree, document.querySelector('#button')!);
+
+ expect(path.map((node) => node.element.dataId)).toEqual(['article', 'section', 'main']);
+ });
+
it('starts from the parent when building a path from an extracted container', () => {
document.body.innerHTML = `
diff --git a/packages/page-trail/src/collector/extractors/ContainerTree.ts b/packages/page-trail/src/collector/extractors/ContainerTree.ts
index 19f3d73..d4050ae 100644
--- a/packages/page-trail/src/collector/extractors/ContainerTree.ts
+++ b/packages/page-trail/src/collector/extractors/ContainerTree.ts
@@ -38,6 +38,8 @@ export class ContainerTree {
private readonly root: Element;
private readonly elementRegistry: ElementRegistry;
private readonly nodeByEl = new WeakMap();
+ // Internal reverse edges keep the public tree shape acyclic and serializable.
+ private readonly parentByNode = new WeakMap();
readonly elements: ContainerElement[] = [];
readonly nodes: ContainerTreeNode[] = [];
@@ -125,6 +127,8 @@ export class ContainerTree {
const parent = this.getParentNode(el);
if (parent) {
parent.nodes.push(node);
+ // Keep the reverse edge in sync with the child attachment.
+ this.parentByNode.set(node, parent);
} else {
this.nodes.push(node);
}
@@ -151,8 +155,16 @@ export class ContainerTree {
let current = el.parentElement;
while (current) {
const node = this.nodeByEl.get(current);
- if (node) path.push(node);
-
+ if (node) {
+ path.push(node);
+ // After the nearest container is found, follow tree parents instead of the DOM.
+ let parent = this.parentByNode.get(node);
+ while (parent) {
+ path.push(parent);
+ parent = this.parentByNode.get(parent);
+ }
+ return path;
+ }
if (current === this.root) break;
current = current.parentElement;
}
diff --git a/packages/page-trail/src/collector/extractors/content.ts b/packages/page-trail/src/collector/extractors/content.ts
index 5c31faa..d8a80ba 100644
--- a/packages/page-trail/src/collector/extractors/content.ts
+++ b/packages/page-trail/src/collector/extractors/content.ts
@@ -3,11 +3,11 @@ import type { ContentElement, Scoring } from '../../types/index.ts';
import { scoreContentMeaning, scoreTargetImportance } from '../scoring/index.ts';
import { SELECTOR_CONTENT } from '../selectors.ts';
import { getElementBoundingBox, isElementVisible } from './primitive/view.ts';
-import { getElementText } from './primitive/text.ts';
import { getCssSelector } from './primitive/selector.ts';
import type { ElementRegistry } from '../ElementRegistry.ts';
import { ContainerTree } from './ContainerTree.ts';
import { extractContentElementContext } from './context.ts';
+import { getElementText } from './primitive/text.ts';
// constants
const CONTENT_MIN_TEXT_LENGTH = 5;
diff --git a/packages/page-trail/src/collector/extractors/interactive.ts b/packages/page-trail/src/collector/extractors/interactive.ts
index fb863de..915a6a4 100644
--- a/packages/page-trail/src/collector/extractors/interactive.ts
+++ b/packages/page-trail/src/collector/extractors/interactive.ts
@@ -6,13 +6,16 @@ import { getElementBoundingBox, isAboveTheFold, isElementVisible, isInViewport }
import { isSensitiveElement } from './primitive/sensitive.ts';
import { getInteractiveRole, roleToInteractiveElementType } from './primitive/role.ts';
import { getInteractiveElementLabels } from './primitive/label.ts';
-import { getElementText } from './primitive/text.ts';
import { getInteractiveElementState } from './primitive/state.ts';
import { getCssSelector } from './primitive/selector.ts';
import { getElementLink } from './primitive/link.ts';
import type { ElementRegistry } from '../ElementRegistry.ts';
import { ContainerTree } from './ContainerTree.ts';
import { extractInteractiveElementContext } from './context.ts';
+import { getElementText } from './primitive/text.ts';
+
+// constants
+const TEXT_CONTENT_MAX_LENGTH = 240;
interface ExtractInteractiveElementsOptions {
elementsLimit: number;
@@ -58,7 +61,7 @@ export function extractInteractiveElements(
// compute only necessary data for scoring the candidates
const labels = getInteractiveElementLabels(el);
- const text = getElementText(el);
+ const text = getElementText(el, { maxLength: TEXT_CONTENT_MAX_LENGTH });
const state = getInteractiveElementState(el);
const bbox = getElementBoundingBox(el);
const meaningScore = scoreInteractiveMeaning({ role, type, labels, text, state, bbox });
diff --git a/packages/page-trail/src/collector/extractors/primitive/label.test.ts b/packages/page-trail/src/collector/extractors/primitive/label.test.ts
index 50ff9b6..31cc256 100644
--- a/packages/page-trail/src/collector/extractors/primitive/label.test.ts
+++ b/packages/page-trail/src/collector/extractors/primitive/label.test.ts
@@ -15,6 +15,21 @@ describe('getElementAttrAriaLabelledBy', () => {
expect(getElementAttrAriaLabelledBy(button)).toBe('First second label');
});
+ it('limits resolved referenced label text', () => {
+ document.body.innerHTML = `
+ ${'Long label text '.repeat(20)}
+
+ `;
+
+ const button = document.querySelector('button')!;
+ const label = getElementAttrAriaLabelledBy(button);
+
+ expect(label?.length).toBeLessThanOrEqual(120);
+ expect(label).toBe(
+ 'Long label text Long label text Long label text Long label text Long label text Long label text Long label text Long',
+ );
+ });
+
it('returns undefined when the attribute is missing', () => {
expect(getElementAttrAriaLabelledBy(document.createElement('button'))).toBeUndefined();
});
@@ -122,4 +137,19 @@ describe('getInteractiveElementLabels', () => {
it('returns an empty array when no labels are found', () => {
expect(getInteractiveElementLabels(document.createElement('div'))).toEqual([]);
});
+
+ it('limits long interactive label values', () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const button = document.querySelector('button')!;
+ const [label] = getInteractiveElementLabels(button);
+
+ expect(label.source).toBe('aria-label');
+ expect(label.value).toBe(
+ 'Long button label Long button label Long button label Long button label Long button label Long button label Long button',
+ );
+ expect(label.value.length).toBeLessThanOrEqual(120);
+ });
});
diff --git a/packages/page-trail/src/collector/extractors/primitive/label.ts b/packages/page-trail/src/collector/extractors/primitive/label.ts
index d29cf46..efab48e 100644
--- a/packages/page-trail/src/collector/extractors/primitive/label.ts
+++ b/packages/page-trail/src/collector/extractors/primitive/label.ts
@@ -2,6 +2,15 @@ import { dedupeBy, normalizeText } from '../../../utils/index.ts';
import type { ContainerElementLabel, InteractiveElementLabel } from '../../../types/index.ts';
import { getContainerHeading } from './heading.ts';
+// constants
+const LABEL_MAX_LENGTH = 120;
+
+function normaliseLabelText(rawLabel: string): string {
+ return normalizeText(rawLabel, {
+ maxLength: LABEL_MAX_LENGTH,
+ });
+}
+
/**
* Resolves the `aria-labelledby` attribute of an element into a normalized label string.
*
@@ -19,7 +28,7 @@ export function getElementAttrAriaLabelledBy(el: Element): string | undefined {
return ariaLabelledBy
.split(/\s+/)
.map((id) => el.ownerDocument.getElementById(id)?.textContent)
- .map((part) => normalizeText(part ?? ''))
+ .map((part) => normaliseLabelText(part ?? ''))
.filter(Boolean)
.join(' ');
}
@@ -51,23 +60,23 @@ export function getContainerElementLabels(el: Element): ContainerElementLabel[]
// 2. aria-label
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
- labels.push({ value: normalizeText(ariaLabel), source: 'aria-label' });
+ labels.push({ value: normaliseLabelText(ariaLabel), source: 'aria-label' });
}
// 3. legend
const legend = Array.from(el.children).find((child) => child.tagName.toLowerCase() === 'legend');
if (legend) {
- labels.push({ value: normalizeText(legend.textContent), source: 'legend' });
+ labels.push({ value: normaliseLabelText(legend.textContent), source: 'legend' });
}
// 4,5. heading/subheading
const heading = getContainerHeading(el);
if (heading) {
const source = /^h[1-4]$/i.test(heading.tagName) ? 'heading' : 'subheading';
- labels.push({ value: normalizeText(heading.textContent), source });
+ labels.push({ value: normaliseLabelText(heading.textContent), source });
}
// 6. title
const title = el.getAttribute('title');
if (title) {
- labels.push({ value: normalizeText(title), source: 'title' });
+ labels.push({ value: normaliseLabelText(title), source: 'title' });
}
return dedupeBy(
labels.filter((l) => Boolean(l.value)),
@@ -105,7 +114,7 @@ export function getInteractiveElementLabels(el: Element): InteractiveElementLabe
// 2. aria-label
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
- labels.push({ value: normalizeText(ariaLabel), source: 'aria-label' });
+ labels.push({ value: normaliseLabelText(ariaLabel), source: 'aria-label' });
}
// 3.
if (el.id) {
@@ -113,42 +122,42 @@ export function getInteractiveElementLabels(el: Element): InteractiveElementLabe
const labelFor = el.ownerDocument.querySelector(`label[for="${escapedId}"]`);
if (labelFor) {
- labels.push({ value: normalizeText(labelFor.textContent), source: 'label-for' });
+ labels.push({ value: normaliseLabelText(labelFor.textContent), source: 'label-for' });
}
}
// 4. wrapping
const parentLabel = el.closest?.('label');
if (parentLabel) {
- labels.push({ value: normalizeText(parentLabel.textContent), source: 'label-wrapper' });
+ labels.push({ value: normaliseLabelText(parentLabel.textContent), source: 'label-wrapper' });
}
// 5. value
if ('value' in el) {
labels.push({
- value: normalizeText((el as HTMLInputElement | HTMLButtonElement).value),
+ value: normaliseLabelText((el as HTMLInputElement | HTMLButtonElement).value),
source: 'value',
});
}
// 6. placeholder
if ('placeholder' in el) {
labels.push({
- value: normalizeText((el as HTMLInputElement | HTMLTextAreaElement).placeholder),
+ value: normaliseLabelText((el as HTMLInputElement | HTMLTextAreaElement).placeholder),
source: 'placeholder',
});
}
// 7. alt
const alt = el.getAttribute('alt');
if (alt) {
- labels.push({ value: normalizeText(alt), source: 'alt' });
+ labels.push({ value: normaliseLabelText(alt), source: 'alt' });
}
// 8. title
const title = el.getAttribute('title');
if (title) {
- labels.push({ value: normalizeText(title), source: 'title' });
+ labels.push({ value: normaliseLabelText(title), source: 'title' });
}
// 9. name
if ('name' in el) {
labels.push({
- value: normalizeText((el as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).name),
+ value: normaliseLabelText((el as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).name),
source: 'name',
});
}
diff --git a/packages/page-trail/src/collector/extractors/primitive/text.test.ts b/packages/page-trail/src/collector/extractors/primitive/text.test.ts
index 086455e..471b0f0 100644
--- a/packages/page-trail/src/collector/extractors/primitive/text.test.ts
+++ b/packages/page-trail/src/collector/extractors/primitive/text.test.ts
@@ -10,7 +10,14 @@ describe('getElementText', () => {
expect(getElementText(el)).toBe('Hello world!');
});
- it('returns undefined for empty text', () => {
- expect(getElementText(document.createElement('div'))).toBeUndefined();
+ it('returns an empty string for empty text', () => {
+ expect(getElementText(document.createElement('div'))).toBe('');
+ });
+
+ it('limits normalized text content when maxLength is provided', () => {
+ const el = document.createElement('div');
+ el.textContent = ' Open the account settings panel ';
+
+ expect(getElementText(el, { maxLength: 18 })).toBe('Open the account');
});
});
diff --git a/packages/page-trail/src/collector/extractors/primitive/text.ts b/packages/page-trail/src/collector/extractors/primitive/text.ts
index 5e93cb6..c7256c4 100644
--- a/packages/page-trail/src/collector/extractors/primitive/text.ts
+++ b/packages/page-trail/src/collector/extractors/primitive/text.ts
@@ -1,14 +1,13 @@
import { normalizeText } from '../../../utils/index.ts';
/**
- * Extracts normalized user-visible text from an element.
+ * Extracts normalized text content from an element.
*
- * Returns undefined if the element does not contain meaningful text.
+ * Returns an empty string when the element has no text after normalization.
*/
-export function getElementText(el: Element): string | undefined {
+export function getElementText(el: Element, options: { maxLength?: number } = {}): string | undefined {
const text = el.textContent;
- if (!text) return undefined;
+ if (text == null) return undefined;
- const normalized = normalizeText(text);
- return normalized || undefined;
+ return normalizeText(text, options);
}
diff --git a/packages/page-trail/src/collector/scoring/meaning/content.test.ts b/packages/page-trail/src/collector/scoring/meaning/content.test.ts
index efac60d..55b2f93 100644
--- a/packages/page-trail/src/collector/scoring/meaning/content.test.ts
+++ b/packages/page-trail/src/collector/scoring/meaning/content.test.ts
@@ -32,8 +32,8 @@ describe('scoreContentMeaning', () => {
);
});
- it('penalizes empty and whitespace-only text', () => {
- expect(scoreContentMeaning({ ...baseScoringData, text: ' \n\t ' })).toEqual({
+ it('penalizes empty normalized text', () => {
+ expect(scoreContentMeaning({ ...baseScoringData, text: '' })).toEqual({
score: -3,
value: 0.1,
features: ['TYPE_TEXT_INC_1', 'TEXT_LENGTH_EMPTY_DEC_4'],
diff --git a/packages/page-trail/src/collector/scoring/meaning/content.ts b/packages/page-trail/src/collector/scoring/meaning/content.ts
index d8c1a33..5ae95d6 100644
--- a/packages/page-trail/src/collector/scoring/meaning/content.ts
+++ b/packages/page-trail/src/collector/scoring/meaning/content.ts
@@ -1,5 +1,4 @@
import type { ContentElementType } from '../../../types/index.ts';
-import { normalizeText } from '../../../utils/index.ts';
import { ScoringFeat, type ScoringResult } from '../ScoringFeat.ts';
const scoringWeights = {
@@ -30,14 +29,12 @@ const scoringWeights = {
const scoringFeat = ScoringFeat.create(scoringWeights);
function readTextLengthScoringCategory(text: string): keyof typeof scoringWeights.textLength {
- const length = normalizeText(text).length;
-
- if (length === 0) return 'empty';
- if (length < 20) return 'tooShort';
- if (length < 60) return 'short';
- if (length < 100) return 'meaningful';
- if (length < 300) return 'optimal';
- if (length <= 600) return 'long';
+ if (text.length === 0) return 'empty';
+ if (text.length < 20) return 'tooShort';
+ if (text.length < 60) return 'short';
+ if (text.length < 100) return 'meaningful';
+ if (text.length < 300) return 'optimal';
+ if (text.length <= 600) return 'long';
return 'tooLong';
}
diff --git a/packages/page-trail/src/collector/scoring/meaning/interactive.test.ts b/packages/page-trail/src/collector/scoring/meaning/interactive.test.ts
index f1b627a..8b830d1 100644
--- a/packages/page-trail/src/collector/scoring/meaning/interactive.test.ts
+++ b/packages/page-trail/src/collector/scoring/meaning/interactive.test.ts
@@ -7,7 +7,7 @@ const baseScoringData: InteractiveMeaningScoringData = {
role: 'button',
type: 'button',
labels: [],
- text: 'Save',
+ text: 'Submit',
state: {},
bbox: { top: 0, left: 0, width: 100, height: 40, right: 100, bottom: 40 },
};
@@ -15,9 +15,9 @@ const baseScoringData: InteractiveMeaningScoringData = {
describe('scoreInteractiveMeaning', () => {
it('returns raw score, normalized score, and selected features', () => {
expect(scoreInteractiveMeaning(baseScoringData)).toEqual({
- score: 9,
- value: 20 / 22,
- features: ['TYPE_ACTION_INC_3', 'ROLE_CRITICAL_INC_3', 'NAME_TEXT_ONLY_INC_2', 'USABILITY_USABLE_INC_1'],
+ score: 8,
+ value: 19 / 22,
+ features: ['TYPE_ACTION_INC_3', 'ROLE_CRITICAL_INC_3', 'NAME_TEXT_ONLY_INC_1', 'USABILITY_USABLE_INC_1'],
});
});
@@ -37,6 +37,30 @@ describe('scoreInteractiveMeaning', () => {
);
});
+ it('scores valid long labels as weak naming signal', () => {
+ expect(
+ scoreInteractiveMeaning({
+ ...baseScoringData,
+ labels: [
+ {
+ source: 'aria-label',
+ value: 'A longer label that is still valid but not concise enough for a strong name in the scoring model',
+ },
+ ],
+ text: undefined,
+ }).features,
+ ).toContain('NAME_WEAK_INC_2');
+ });
+
+ it('penalizes long visible text as noisy naming signal when labels are missing', () => {
+ expect(
+ scoreInteractiveMeaning({
+ ...baseScoringData,
+ text: 'Long visible text content. '.repeat(8),
+ }).features,
+ ).toContain('NAME_NOISY_TEXT_DEC_1');
+ });
+
it('scores critical controls above plain links', () => {
expect(scoreInteractiveMeaning({ ...baseScoringData, role: 'button', type: 'button' }).value).toBeGreaterThan(
scoreInteractiveMeaning({ ...baseScoringData, role: 'link', type: 'link' }).value,
@@ -62,7 +86,7 @@ describe('scoreInteractiveMeaning', () => {
role: 'combobox',
type: 'select',
}).features,
- ).toEqual(['TYPE_SELECTION_INC_2', 'ROLE_USER_FLOW_INC_2', 'NAME_TEXT_ONLY_INC_2', 'USABILITY_USABLE_INC_1']);
+ ).toEqual(['TYPE_SELECTION_INC_2', 'ROLE_USER_FLOW_INC_2', 'NAME_TEXT_ONLY_INC_1', 'USABILITY_USABLE_INC_1']);
});
it('scores state controls as explicit state-change targets', () => {
@@ -75,7 +99,7 @@ describe('scoreInteractiveMeaning', () => {
).toEqual([
'TYPE_SELECTION_INC_2',
'ROLE_STATE_CONTROL_INC_2',
- 'NAME_TEXT_ONLY_INC_2',
+ 'NAME_TEXT_ONLY_INC_1',
'USABILITY_USABLE_INC_1',
]);
});
@@ -113,17 +137,17 @@ describe('scoreInteractiveMeaning', () => {
role: 'link',
type: 'link',
labels: [],
- text: 'Docs',
+ text: 'Docs page',
state: {},
});
expect(score).toEqual({
- score: 5,
- value: 16 / 22,
+ score: 4,
+ value: 15 / 22,
features: [
'TYPE_NAVIGATION_INC_1',
'ROLE_NAVIGATION_INC_1',
- 'NAME_TEXT_ONLY_INC_2',
+ 'NAME_TEXT_ONLY_INC_1',
'USABILITY_USABLE_INC_1',
],
});
diff --git a/packages/page-trail/src/collector/scoring/meaning/interactive.ts b/packages/page-trail/src/collector/scoring/meaning/interactive.ts
index 4a51821..9b253d8 100644
--- a/packages/page-trail/src/collector/scoring/meaning/interactive.ts
+++ b/packages/page-trail/src/collector/scoring/meaning/interactive.ts
@@ -5,9 +5,9 @@ import type {
InteractiveElementState,
InteractiveElementType,
} from '../../../types/index.ts';
-import { normalizeText } from '../../../utils/index.ts';
import { ScoringFeat, type ScoringResult } from '../ScoringFeat.ts';
+// constants
const SMALL_INTERACTIVE_AREA = 24 * 24;
const scoringWeights = {
@@ -33,10 +33,14 @@ const scoringWeights = {
supporting: 1,
},
name: {
- // Explicit labels usually provide the strongest local meaning.
+ // Concise labels usually provide the strongest local meaning.
strong: 3,
- // Visible text is useful, but can be less reliable than explicit labels.
- textOnly: 2,
+ // Valid but longer labels still provide explicit naming signal.
+ weak: 2,
+ // Visible text can name the target, but is less reliable than an explicit label.
+ textOnly: 1,
+ // Long visible text is often wrapper/card content rather than a precise target name.
+ noisyText: -1,
// Unnamed interactive elements are hard to understand and ground.
missing: -3,
},
@@ -83,8 +87,16 @@ function readNameScoringCategory(
labels: InteractiveElementLabel[],
text: string | undefined,
): keyof typeof scoringWeights.name {
- if (labels.length > 0) return 'strong';
- if (normalizeText(text ?? '').length > 0) return 'textOnly';
+ if (labels.some((label) => label.value.length >= 5 && label.value.length <= 80)) {
+ return 'strong';
+ }
+ if (labels.some((label) => label.value.length >= 5)) {
+ return 'weak';
+ }
+ if (text !== undefined) {
+ if (text.length > 160) return 'noisyText';
+ if (text.length >= 5) return 'textOnly';
+ }
return 'missing';
}
diff --git a/packages/page-trail/src/semantic/document/markdown.example.md b/packages/page-trail/src/semantic/document/markdown.example.md
index 88e2ad1..28e3d67 100644
--- a/packages/page-trail/src/semantic/document/markdown.example.md
+++ b/packages/page-trail/src/semantic/document/markdown.example.md
@@ -15,7 +15,7 @@ Basic information about the current page.
An outline of the detected page structure.
- Main content. Name: Main
- - Navigation. Name: Tabs
+ - Navigation. Name: Tabs
## Sample headings
diff --git a/packages/page-trail/src/utils/text.test.ts b/packages/page-trail/src/utils/text.test.ts
index 6949898..fdccbad 100644
--- a/packages/page-trail/src/utils/text.test.ts
+++ b/packages/page-trail/src/utils/text.test.ts
@@ -20,6 +20,14 @@ describe('normalizeText', () => {
it('returns an empty string for whitespace-only input', () => {
expect(normalizeText(' \n\t ')).toBe('');
});
+
+ it('truncates normalized text to the provided maximum length', () => {
+ expect(normalizeText('Hello world from FlowForge', { maxLength: 12 })).toBe('Hello world');
+ });
+
+ it('cuts at the maximum length when no whitespace exists before the limit', () => {
+ expect(normalizeText('FlowForge', { maxLength: 4 })).toBe('Flow');
+ });
});
describe('toUpperSnakeCase', () => {
diff --git a/packages/page-trail/src/utils/text.ts b/packages/page-trail/src/utils/text.ts
index bd5c219..9f44184 100644
--- a/packages/page-trail/src/utils/text.ts
+++ b/packages/page-trail/src/utils/text.ts
@@ -1,15 +1,32 @@
/**
- * Normalizes text by collapsing consecutive whitespace into a single space
- * and trimming leading/trailing whitespace.
+ * Normalizes text by collapsing consecutive whitespace into a single space,
+ * trimming leading/trailing whitespace, and optionally truncating to a maximum
+ * length.
+ *
+ * Truncation preserves the requested maximum length exactly. When possible, it
+ * cuts at the last whitespace before the limit to avoid splitting words.
*
* @param text Input text to normalize.
+ * @param options
+ * @param options.maxLength Maximum length of the normalized text.
* @returns Normalized text.
*/
-export function normalizeText(text: string) {
- return text
+export function normalizeText(text: string, options: { maxLength?: number } = {}): string {
+ const normalized = text
.replace(/\s+/g, ' ')
.replace(/\s+([.,!?;:])/g, '$1')
.trim();
+
+ if (options.maxLength === undefined || normalized.length <= options.maxLength) {
+ return normalized;
+ }
+ const sliced = normalized.slice(0, options.maxLength);
+ const lastWhitespaceIndex = sliced.lastIndexOf(' ');
+
+ if (lastWhitespaceIndex > 0) {
+ return sliced.slice(0, lastWhitespaceIndex);
+ }
+ return sliced;
}
/**
From d692c6c081f211ee8d2457a63582154891bc209e Mon Sep 17 00:00:00 2001
From: cerberus-ab
Date: Sun, 23 Aug 2026 14:49:05 +0200
Subject: [PATCH 15/15] Updated docs
---
CHANGELOG.md | 17 +++++++++++++++++
apps/backend/README.md | 11 +++++++----
apps/extension/README.md | 6 ++----
.../Understanding/Understanding.css | 3 +++
.../Understanding/Understanding.tsx | 2 +-
.../src/shared/components/Card/Card.css | 3 ---
.../src/shared/components/Card/Card.tsx | 14 +++++++-------
.../extension/src/shared/img/footer-bg-sm.png | Bin 0 -> 159741 bytes
.../src/shared/img/footer-bg-sm.webp | Bin 0 -> 12854 bytes
docs/ARCHITECTURE.md | 13 ++++++++-----
docs/BACKLOG.md | 18 ++++++++++++++----
docs/DOM-TO-RAG-PIPELINE.md | 12 +++++-------
docs/assets/dom-rag-pipeline.png | Bin 222564 -> 249410 bytes
docs/assets/dom-rag-pipeline.webp | Bin 58714 -> 63492 bytes
docs/assets/footer-bg.png | Bin 0 -> 860780 bytes
15 files changed, 64 insertions(+), 35 deletions(-)
create mode 100644 apps/extension/src/shared/img/footer-bg-sm.png
create mode 100644 apps/extension/src/shared/img/footer-bg-sm.webp
create mode 100644 docs/assets/footer-bg.png
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/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/popup/components/Understanding/Understanding.css b/apps/extension/src/popup/components/Understanding/Understanding.css
index 66bd4e9..d691bcf 100644
--- a/apps/extension/src/popup/components/Understanding/Understanding.css
+++ b/apps/extension/src/popup/components/Understanding/Understanding.css
@@ -1,4 +1,7 @@
/* Understanding */
+.flowforge-understanding-container {
+ background: transparent;
+}
.flowforge-understanding-list {
}
.flowforge-understanding-list ul {
diff --git a/apps/extension/src/popup/components/Understanding/Understanding.tsx b/apps/extension/src/popup/components/Understanding/Understanding.tsx
index 7bd4884..0021a71 100644
--- a/apps/extension/src/popup/components/Understanding/Understanding.tsx
+++ b/apps/extension/src/popup/components/Understanding/Understanding.tsx
@@ -9,7 +9,7 @@ interface UnderstandingProps {
export function Understanding({ onOpenPageInspector }: UnderstandingProps) {
return (
-
+
-
diff --git a/apps/extension/src/shared/components/Card/Card.css b/apps/extension/src/shared/components/Card/Card.css
index aa68d0b..0565b59 100644
--- a/apps/extension/src/shared/components/Card/Card.css
+++ b/apps/extension/src/shared/components/Card/Card.css
@@ -25,9 +25,6 @@
.flowforge-card__footer {
padding-top: var(--flowforge-pad-sm);
}
-.flowforge-card--transparent {
- background: transparent;
-}
.flowforge-card__text:last-child,
.flowforge-card__text:has(+ .flowforge-card__footer) {
padding-bottom: 0;
diff --git a/apps/extension/src/shared/components/Card/Card.tsx b/apps/extension/src/shared/components/Card/Card.tsx
index 34a9453..29f4105 100644
--- a/apps/extension/src/shared/components/Card/Card.tsx
+++ b/apps/extension/src/shared/components/Card/Card.tsx
@@ -6,9 +6,9 @@ interface CardProps {
text?: string;
variant?: 'primary' | 'secondary';
direction?: 'none' | 'left';
- transparent?: boolean;
twinkle?: boolean;
error?: boolean;
+ className?: string;
children?: ComponentChildren;
}
@@ -17,27 +17,27 @@ export function Card({
text,
variant = 'primary',
direction = 'none',
- transparent = false,
twinkle = false,
error = false,
+ className,
children,
}: CardProps) {
- const className = [
+ const classNames = [
'flowforge-card',
`flowforge-card--${variant}`,
direction !== 'none' && `flowforge-card--${direction}`,
- transparent && 'flowforge-card--transparent',
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 (