feat(P6/T05): cross-framework annotation launcher JS widget

ihf-annotation-launcher.js: vanilla JS, no framework dependency. Scans DOM for
data-widget-id elements, injects Annotate trigger, opens inline form, POSTs to
/widgets/:widgetId/annotations. Works in React/Vue-rendered pages via
MutationObserver. Feature-gated by IHP_ANNOTATION_LAUNCHER=true env var
(Config.hs AnnotationLauncherEnabled, FrontController layout conditional).
Docs: docs/annotation-launcher.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:17:43 +00:00
parent 32bb003f3b
commit 04eb4643b0
5 changed files with 357 additions and 4 deletions

View File

@@ -3,9 +3,13 @@ module Config where
import IHP.Prelude
import IHP.Environment
import IHP.FrameworkConfig
import System.Environment (lookupEnv)
-- | Feature flag: set IHP_ANNOTATION_LAUNCHER=true to inject the JS launcher.
newtype AnnotationLauncherEnabled = AnnotationLauncherEnabled Bool deriving (Typeable)
config :: ConfigBuilder
config = do
-- See https://ihp.digitallyinduced.com/Guide/config.html
-- for what you can do here
launcherEnv <- liftIO (lookupEnv "IHP_ANNOTATION_LAUNCHER")
option (AnnotationLauncherEnabled (launcherEnv == Just "true"))
pure ()

View File

@@ -2,9 +2,11 @@ module Web.FrontController where
import IHP.RouterPrelude
import IHP.LoginSupport.Middleware
import IHP.ControllerPrelude (getAppConfig)
import Generated.Types
import Web.Types
import Web.Routes ()
import Config (AnnotationLauncherEnabled (..))
-- Controllers
import Web.Controller.Hubs ()
@@ -47,6 +49,13 @@ instance InitControllerContext WebApplication where
setLayout defaultLayout
initAuthentication @User
annotationLauncherScript :: (?context :: ControllerContext) => Html
annotationLauncherScript =
let AnnotationLauncherEnabled enabled = getAppConfig @AnnotationLauncherEnabled
in if enabled
then [hsx|<script src="/js/ihf-annotation-launcher.js"></script>|]
else mempty
defaultLayout :: Layout
defaultLayout inner = [hsx|
<!DOCTYPE html>
@@ -59,6 +68,7 @@ defaultLayout inner = [hsx|
<link rel="stylesheet" href="/app.css" />
<script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script>
{annotationLauncherScript}
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">

View File

@@ -0,0 +1,79 @@
# IHF Annotation Launcher
`static/js/ihf-annotation-launcher.js` is a self-contained vanilla JS module
that injects an **Annotate** button adjacent to every element that carries a
`data-widget-id` attribute.
It works in IHP server-rendered pages **and** in React/Vue pages where IHP does
not own the DOM — the launcher relies solely on the presence of `data-widget-id`,
not on any framework-specific structure.
## Activation
Set the environment variable before starting the server:
```bash
IHP_ANNOTATION_LAUNCHER=true devenv up
```
This causes IHP to emit the following tag in every page's `<head>`:
```html
<script src="/js/ihf-annotation-launcher.js"></script>
```
When `IHP_ANNOTATION_LAUNCHER` is absent or not `"true"`, the script is not
loaded.
## Data attributes
| Attribute | Required | Description |
|--------------------|----------|--------------------------------------------------|
| `data-widget-id` | Yes | UUID of the widget (from the widget registry) |
| `data-hub-id` | No | UUID of the hub — read from element or nearest ancestor |
| `data-view-context`| No | Logical UI location (informational) |
## Usage in React
Because the launcher uses `MutationObserver`, it will pick up React-rendered
elements after mount:
```jsx
function MyWidget({ widgetId, hubId }) {
return (
<div data-widget-id={widgetId} data-hub-id={hubId}>
<button>Do something</button>
</div>
);
}
```
No other integration is required. The launcher injects the Annotate button and
handles POST submission to `/widgets/:widgetId/annotations`.
## Programmatic re-scan
If your SPA does client-side navigation without full page reloads, call:
```js
window.IHFAnnotationLauncher.scan();
```
## Submission
The launcher POSTs `application/x-www-form-urlencoded` to:
```
POST /widgets/:widgetId/annotations
```
Fields submitted: `body`, `category`, `severity` (fixed `low`), CSRF token.
Responses:
- `200` or redirect → success (form closes with confirmation)
- Any non-OK response → inline error message
## CSRF
The launcher reads the CSRF token from `<meta name="csrf-token" content="...">`.
IHP emits this tag automatically in authenticated layouts.

View File

@@ -0,0 +1,260 @@
/**
* IHF Annotation Launcher — ihf-annotation-launcher.js
*
* A self-contained vanilla JS module (no framework dependency) that injects
* an "Annotate" trigger adjacent to every element carrying a `data-widget-id`
* attribute. Works in IHP server-rendered pages and in React/Vue pages where
* IHP does not own the DOM.
*
* Activation: include this script in the page layout (or load it dynamically).
* It is gated by the IHP_ANNOTATION_LAUNCHER environment variable on the server,
* which controls whether the <script> tag is emitted at all.
*
* Data attributes read:
* data-widget-id — UUID of the widget (required)
* data-hub-id — UUID of the owning hub (read from element or nearest ancestor)
*
* The annotation form POSTs to /annotations (IHP's existing AnnotationsController).
*
* Usage (static HTML test):
* <div data-widget-id="<uuid>" data-hub-id="<uuid>" data-view-context="main">
* <button>My Widget</button>
* </div>
* <script src="/js/ihf-annotation-launcher.js"></script>
*/
(function () {
'use strict';
var LAUNCHER_CLASS = 'ihf-launcher-injected';
var TRIGGER_CLASS = 'ihf-annotate-trigger';
var FORM_CLASS = 'ihf-annotate-form';
var CATEGORY_OPTIONS = [
{ value: 'friction', label: 'Friction' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'confusing', label: 'Confusing' },
{ value: 'praise', label: 'Praise' },
{ value: 'bug', label: 'Bug' },
{ value: 'other', label: 'Other' },
];
/** Find data-hub-id from element or nearest ancestor. */
function resolveHubId(el) {
var cur = el;
while (cur && cur !== document.body) {
var v = cur.getAttribute('data-hub-id');
if (v) return v;
cur = cur.parentElement;
}
return null;
}
/** Build the inline annotation form element. */
function buildForm(widgetId, hubId, trigger) {
var form = document.createElement('div');
form.className = FORM_CLASS;
form.style.cssText = [
'position:absolute',
'z-index:9999',
'background:#fff',
'border:1px solid #d1d5db',
'border-radius:8px',
'padding:12px',
'box-shadow:0 4px 16px rgba(0,0,0,0.12)',
'width:280px',
'font-family:sans-serif',
'font-size:13px',
].join(';');
var categoryOptions = CATEGORY_OPTIONS.map(function (o) {
return '<option value="' + o.value + '">' + o.label + '</option>';
}).join('');
form.innerHTML = [
'<div style="font-weight:600;margin-bottom:8px;color:#374151">Add Annotation</div>',
'<textarea id="ihf-body" placeholder="Describe your feedback…"',
' style="width:100%;height:70px;resize:vertical;border:1px solid #d1d5db;',
' border-radius:4px;padding:6px;font-size:12px;box-sizing:border-box"></textarea>',
'<select id="ihf-category"',
' style="width:100%;margin-top:6px;border:1px solid #d1d5db;border-radius:4px;',
' padding:5px;font-size:12px">',
categoryOptions,
'</select>',
'<div style="display:flex;gap:6px;margin-top:8px">',
' <button id="ihf-submit"',
' style="background:#4f46e5;color:#fff;border:none;border-radius:4px;',
' padding:5px 12px;font-size:12px;cursor:pointer">Submit</button>',
' <button id="ihf-cancel"',
' style="background:transparent;border:1px solid #d1d5db;border-radius:4px;',
' padding:5px 10px;font-size:12px;cursor:pointer">Cancel</button>',
' <span id="ihf-msg" style="align-self:center;font-size:11px;color:#6b7280"></span>',
'</div>',
].join('');
// Position below the trigger button.
function reposition() {
var rect = trigger.getBoundingClientRect();
form.style.top = (window.scrollY + rect.bottom + 4) + 'px';
form.style.left = (window.scrollX + rect.left) + 'px';
}
document.body.appendChild(form);
form.style.position = 'absolute';
reposition();
var bodyEl = form.querySelector('#ihf-body');
var catEl = form.querySelector('#ihf-category');
var submitBtn = form.querySelector('#ihf-submit');
var cancelBtn = form.querySelector('#ihf-cancel');
var msgEl = form.querySelector('#ihf-msg');
function closeForm() {
if (form.parentNode) form.parentNode.removeChild(form);
}
cancelBtn.addEventListener('click', closeForm);
// Close on outside click.
function onOutside(e) {
if (!form.contains(e.target) && e.target !== trigger) {
closeForm();
document.removeEventListener('mousedown', onOutside);
}
}
setTimeout(function () {
document.addEventListener('mousedown', onOutside);
}, 10);
submitBtn.addEventListener('click', function () {
var body = bodyEl.value.trim();
var category = catEl.value;
if (!body) {
msgEl.textContent = 'Please enter feedback.';
msgEl.style.color = '#ef4444';
return;
}
submitBtn.disabled = true;
msgEl.textContent = 'Saving…';
msgEl.style.color = '#6b7280';
// Fetch CSRF token from meta tag if present (IHP injects it).
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// Build form-encoded body matching IHP's AnnotationsController.
var payload = [
'body=' + encodeURIComponent(body),
'category=' + encodeURIComponent(category),
'severity=low',
'IHFToken=' + encodeURIComponent(csrfToken),
].join('&');
fetch('/widgets/' + widgetId + '/annotations', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken,
},
body: payload,
})
.then(function (res) {
if (res.ok || res.redirected) {
msgEl.textContent = '✓ Saved';
msgEl.style.color = '#16a34a';
setTimeout(closeForm, 800);
} else {
return res.text().then(function (t) {
throw new Error('Server returned ' + res.status);
});
}
})
.catch(function (err) {
submitBtn.disabled = false;
msgEl.textContent = 'Error: ' + err.message;
msgEl.style.color = '#ef4444';
});
});
bodyEl.focus();
return form;
}
/** Inject an Annotate trigger button adjacent to a widget envelope. */
function injectTrigger(el) {
if (el.classList.contains(LAUNCHER_CLASS)) return;
el.classList.add(LAUNCHER_CLASS);
var widgetId = el.getAttribute('data-widget-id');
var hubId = resolveHubId(el);
if (!widgetId) return;
var trigger = document.createElement('button');
trigger.className = TRIGGER_CLASS;
trigger.textContent = '+ Annotate';
trigger.setAttribute('type', 'button');
trigger.setAttribute('title', 'Add annotation for widget ' + widgetId);
trigger.style.cssText = [
'font-size:11px',
'color:#6b7280',
'background:transparent',
'border:1px solid #e5e7eb',
'border-radius:4px',
'padding:2px 8px',
'cursor:pointer',
'margin-top:4px',
].join(';');
trigger.addEventListener('mouseover', function () { trigger.style.color = '#4f46e5'; trigger.style.borderColor = '#a5b4fc'; });
trigger.addEventListener('mouseout', function () { trigger.style.color = '#6b7280'; trigger.style.borderColor = '#e5e7eb'; });
var activeForm = null;
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (activeForm && activeForm.parentNode) {
activeForm.parentNode.removeChild(activeForm);
activeForm = null;
return;
}
activeForm = buildForm(widgetId, hubId, trigger);
});
el.appendChild(trigger);
}
/** Scan the DOM for widget envelopes and inject triggers. */
function scan() {
var envelopes = document.querySelectorAll('[data-widget-id]');
for (var i = 0; i < envelopes.length; i++) {
injectTrigger(envelopes[i]);
}
}
// Initial scan on DOM ready.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
scan();
}
// Re-scan when IHP AutoRefresh updates the DOM (morphdom fires a custom event).
document.addEventListener('ihp:morphdom:updated', scan);
// Also observe mutations for React/Vue SPA rendering.
if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(function (mutations) {
var shouldScan = false;
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].addedNodes.length > 0) { shouldScan = true; break; }
}
if (shouldScan) scan();
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Expose for programmatic re-scanning.
window.IHFAnnotationLauncher = { scan: scan };
})();

View File

@@ -190,7 +190,7 @@ an `InteractionEvent`; invalid payloads return `422`; contract show page renders
```task
id: IHUB-WP-0006-T04
status: todo
status: done
priority: high
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
```