Skip to content

feat(inline-annotations): opt-in agentation-style pin annotations#7

Open
olyaiy wants to merge 1 commit into
mainfrom
feat/inline-pin-annotations
Open

feat(inline-annotations): opt-in agentation-style pin annotations#7
olyaiy wants to merge 1 commit into
mainfrom
feat/inline-pin-annotations

Conversation

@olyaiy
Copy link
Copy Markdown
Collaborator

@olyaiy olyaiy commented May 14, 2026

Summary

  • Adds an opt-in inline annotation flow inspired by benjitaylor/agentation: clicking the trigger from an empty draft enters a sticky "annotation mode" where users click any page element, leave a comment in an anchored popup, and the SDK drops a numbered pin marker. Pins persist in the draft round and clear on submit.
  • Behavior is gated behind a new inlineAnnotations: { enabled: true } config flag so existing consumers see zero behavior change. Default is off.
  • Pure positioning helpers extracted to src/pin-positioning.ts with unit-test coverage. Shadow-DOM-aware deepElementFromPoint so the picker works inside web components on the host page.

What's new

  • Data model: optional pin?: FeedbackPin field on FeedbackRoundItem (round-trips through existing localStorage draft persistence).
  • State machine: annotationModeActive / inlineAnnotationOpen / editingPinItemId / pendingPinAnchor private fields + render+bind methods for the annotation shell, inline popup, and pin layer (scroll vs. fixed sub-layers for position: fixed ancestors).
  • Live re-anchoring: rAF ticker + ResizeObserver + capture-phase scroll listener re-resolve pins via cssSelector while annotation mode is active, with a movement threshold to avoid useless work.
  • A11y: ARIA roles/labels on the chrome, popup, and pins; two-step Escape (popup → closes popup; mode → exits mode); ⌘/Ctrl+Enter submit hint adapts to platform.
  • Theming: new CSS rules reference existing --obv-feedback-* tokens so light/dark mode work automatically.

Test plan

  • bun run test:typecheck
  • bun run test:unit — 38 pass (15 new for pin-positioning)
  • bun run build — IIFE + ESM clean
  • bun run test:e2e — 16 pass; new spec covers add / edit / delete / Escape flows. Fixture gates the feature via ?inlineAnnotations=true so unrelated specs are untouched.
  • Manual smoke in examples/vanilla/index.html (flag now enabled in that example).

Inspired by benjitaylor/agentation, this adds a sticky "annotation mode"
gated behind a new `inlineAnnotations: { enabled: true }` config flag so
existing consumers see no behavior change.

When enabled, clicking the trigger from an empty draft puts the widget
into annotation mode: the element picker stays sticky, clicking any
element on the host page opens an inline comment popup anchored to that
element, and submitting drops a numbered pin marker. Pins persist across
reloads via the existing localStorage draft round, and are cleared on
successful submit.

Implementation notes:
- `FeedbackPin` lives on each `FeedbackRoundItem` so persistence and
  draft round transport flow through unchanged.
- New `src/pin-positioning.ts` holds pure helpers (anchor math, popup
  placement, shadow-DOM-aware `deepElementFromPoint`, css-selector
  re-resolution) with 15 unit tests.
- A requestAnimationFrame ticker + ResizeObserver + capture-phase scroll
  listener keep pins glued to their target element through scroll and
  reflow; scroll vs. fixed sub-layers handle position: fixed ancestors.
- Two-step Escape: inside popup -> closes popup; in mode -> exits mode.
- Adds CSS using existing theme tokens so light/dark modes work.
- Adds a Playwright spec covering add/edit/delete/escape flows; the
  fixture toggles the feature via `?inlineAnnotations=true` so unrelated
  e2e specs are unaffected.
Copy link
Copy Markdown
Contributor

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT — Medium findings (no blockers)

Summary

  • 🔴 Blocker: 0
  • 🟠 High: 0
  • 🟡 Medium: 2
  • 💡 Suggestion: 3
  • 🔵 Nit: 2

Medium Findings

M1 — isElementFixed treats position: sticky as fixed (src/pin-positioning.ts:84)
Sticky elements behave like relative until stuck. Recording yPx in viewport-space for a pre-stuck sticky element causes the pin to visually drift from the annotated element after the user scrolls. Fix: remove sticky from the fixed check.

M2 — Pins inside aria-hidden="true" container (src/index.ts, renderPinLayer())
Pin <button> elements have aria-label but are wrapped in aria-hidden="true" divs. They are invisible to screen readers and not keyboard-reachable. WCAG 2.1 SC 4.1.2 violation. Fix: remove aria-hidden="true" from both pin layer divs.

Also Noted

  • S1: shouldEnterAnnotationModeOnTrigger() entry conditions undocumented on public config type
  • S2: Full shadowRoot.innerHTML replace on fixed↔scroll pin transition blurs active textarea
  • S3: isElementFixed unit tests only cover the null guard — no sticky coverage

Clean Areas

XSS/injection (all escapeHtml), async safety in handleAnnotationPick, storage parsing, MAX_ROUND_ITEMS guards, ticker/listener lifecycle cleanup, deepElementFromPoint loop safety, popup placement edge cases, E2E coverage.


View full review in Obvious

Comment thread src/pin-positioning.ts
return true;
}
}
const parent = current.parentElement;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — sticky is treated as fixed, causing pin drift for pre-stuck elements.

getComputedStyle().position returns "sticky" regardless of whether the element is currently stuck. A sticky nav bar scrolls with the document until its threshold — clicking it before sticking stores yPx in viewport space, then the pin drifts after scroll.

// Fix: remove sticky from the check
if (position === "fixed") {
  return true;
}

If sticky stuck-state detection is needed in future, use IntersectionObserver against a sentinel — it's the only reliable signal.

Comment thread src/index.ts
.querySelector('[data-inline-popup-submit="true"]')
?.addEventListener("click", (event) => {
event.stopPropagation();
this.handleInlineAnnotationSubmit();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Pin buttons are inside aria-hidden="true", making them invisible to screen readers and unreachable by keyboard.

The individual <button> elements have aria-label="Edit annotation N: ..." but the parent <div aria-hidden="true"> removes all descendants from the accessibility tree, including those buttons. WCAG 2.1 SC 4.1.2.

// Fix: remove aria-hidden from both pin layer divs
<div class="obv-pin-layer obv-pin-layer-scroll">

The pointer-events: none CSS on the layer already handles event isolation — aria-hidden is not needed for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant