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.Prelude
import IHP.Environment import IHP.Environment
import IHP.FrameworkConfig 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 :: ConfigBuilder
config = do config = do
-- See https://ihp.digitallyinduced.com/Guide/config.html launcherEnv <- liftIO (lookupEnv "IHP_ANNOTATION_LAUNCHER")
-- for what you can do here option (AnnotationLauncherEnabled (launcherEnv == Just "true"))
pure () pure ()

View File

@@ -2,9 +2,11 @@ module Web.FrontController where
import IHP.RouterPrelude import IHP.RouterPrelude
import IHP.LoginSupport.Middleware import IHP.LoginSupport.Middleware
import IHP.ControllerPrelude (getAppConfig)
import Generated.Types import Generated.Types
import Web.Types import Web.Types
import Web.Routes () import Web.Routes ()
import Config (AnnotationLauncherEnabled (..))
-- Controllers -- Controllers
import Web.Controller.Hubs () import Web.Controller.Hubs ()
@@ -47,6 +49,13 @@ instance InitControllerContext WebApplication where
setLayout defaultLayout setLayout defaultLayout
initAuthentication @User 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 :: Layout
defaultLayout inner = [hsx| defaultLayout inner = [hsx|
<!DOCTYPE html> <!DOCTYPE html>
@@ -59,6 +68,7 @@ defaultLayout inner = [hsx|
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="/app.css" />
<script src="/vendor/morphdom.js"></script> <script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script> <script src="/vendor/ihp-auto-refresh.js"></script>
{annotationLauncherScript}
</head> </head>
<body class="bg-gray-50 text-gray-900"> <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"> <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 ```task
id: IHUB-WP-0006-T04 id: IHUB-WP-0006-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db" state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
``` ```