feat(P6/T06): React adapter specification and reference example

ihf-react-adapter.js: useWidgetEnvelope (EnvelopeEmissionContract v1.0 props),
withWidgetEnvelope HOC, useInteractionReporter (POSTs to reporting contract endpoint).
Plain ESM module — no IHP build toolchain required. docs/react-adapter.md with
usage examples. static/ihf-react-test.html: React widget + native IHP widget on
same page demonstrating annotation launcher pickup and reportEvent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:19:26 +00:00
parent 04eb4643b0
commit 803376b09a
4 changed files with 472 additions and 1 deletions

132
docs/react-adapter.md Normal file
View File

@@ -0,0 +1,132 @@
# IHF React Adapter
`static/js/ihf-react-adapter.js` provides three exports that let React
components participate in the IHF widget protocol:
| Export | What it does |
|------------------------|---------------------------------------------------------|
| `useWidgetEnvelope` | Hook — returns `data-*` props for the envelope |
| `withWidgetEnvelope` | HOC — wraps a component with the envelope div |
| `useInteractionReporter` | Hook — returns `reportEvent(type)` for API submission |
The adapter conforms to:
- **EnvelopeEmissionContract v1.0** — emits `data-widget-id`, `data-view-context`, `data-hub-id`
- **InteractionReportingContract v1.0** — POSTs to `/api/v1/interaction-events` with Bearer auth
No build toolchain is added to the IHP project. Consumers bundle this file
themselves (webpack, vite, esbuild, etc.) or load it via `<script type="module">`.
---
## useWidgetEnvelope
```jsx
import { useWidgetEnvelope } from '/js/ihf-react-adapter.js';
function MetricsPanel({ widgetId, hubId }) {
const { ref, envelopeProps } = useWidgetEnvelope(
widgetId,
hubId,
'ops-dashboard', // viewContext
{ policyScope: 'internal' } // optional
);
return (
<div ref={ref} {...envelopeProps} className="p-4 border rounded">
<h2>Metrics</h2>
{/* content */}
</div>
);
}
```
The `envelopeProps` object contains all required `data-*` attributes.
The `ref` allows the annotation launcher to find the element.
---
## withWidgetEnvelope
```jsx
import { withWidgetEnvelope } from '/js/ihf-react-adapter.js';
// Wrap at module definition time, supplying stable IDs:
const GovernedMetricsPanel = withWidgetEnvelope(
MetricsPanel,
'a3f1c2b4-...', // widgetId (UUID from widget registry)
'b9e2d3a1-...', // hubId
'ops-dashboard' // viewContext
);
// Use anywhere:
function Dashboard() {
return <GovernedMetricsPanel title="CPU Usage" />;
}
```
The HOC wraps the component in an envelope `<div>` carrying the `data-*`
attributes. The inner component receives its props unchanged.
---
## useInteractionReporter
```jsx
import { useInteractionReporter } from '/js/ihf-react-adapter.js';
function ActionButton({ widgetId, hubId, apiKey }) {
const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
async function handleClick() {
await reportEvent('clicked');
// proceed with action
}
return <button onClick={handleClick}>Run Report</button>;
}
```
Accepted event types (InteractionReportingContract v1.0):
`clicked`, `viewed`, `submitted`, `dismissed`, `errored`
The `apiKey` is the hub's bearer token (`hubs.api_key`). Retrieve it from
your app's server-side config — do not embed it in public bundle output.
---
## Combined example
```jsx
import {
useWidgetEnvelope,
useInteractionReporter,
} from '/js/ihf-react-adapter.js';
function GovernedButton({ widgetId, hubId, apiKey, label, onAction }) {
const { envelopeProps } = useWidgetEnvelope(widgetId, hubId, 'action-bar');
const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
async function handleClick() {
await reportEvent('clicked');
onAction();
}
return (
<div {...envelopeProps}>
<button onClick={handleClick}>{label}</button>
</div>
);
}
```
The annotation launcher (`ihf-annotation-launcher.js`) will automatically
detect the `data-widget-id` attribute and inject an Annotate trigger alongside
this component if `IHP_ANNOTATION_LAUNCHER=true`.
---
## Test fixture
`static/ihf-react-test.html` demonstrates a React widget alongside an
IHP-rendered widget on the same page. Open it directly in the browser
(no server needed for the React side).

135
static/ihf-react-test.html Normal file
View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IHF React Adapter — Test Fixture</title>
<style>
body { font-family: sans-serif; padding: 24px; background: #f9fafb; }
h1 { font-size: 1.4rem; margin-bottom: 4px; }
p { color: #6b7280; font-size: 0.9rem; margin-bottom: 24px; }
.card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 16px; margin-bottom: 16px; max-width: 480px;
}
.card h2 { font-size: 1rem; margin: 0 0 8px; }
.label { font-size: 0.7rem; color: #9ca3af; margin-bottom: 4px; }
button {
background: #4f46e5; color: #fff; border: none; border-radius: 4px;
padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
}
button:hover { background: #4338ca; }
pre { background: #f3f4f6; border-radius: 4px; padding: 8px; font-size: 0.75rem; overflow: auto; }
.badge {
display: inline-block; font-size: 0.65rem; font-weight: 600;
padding: 2px 6px; border-radius: 4px; margin-left: 6px;
}
.badge-react { background: #dbeafe; color: #1d4ed8; }
.badge-native { background: #dcfce7; color: #15803d; }
</style>
</head>
<body>
<h1>IHF React Adapter — Test Fixture</h1>
<p>
Demonstrates a React-backed widget alongside a native IHP widget on the same page.
Both carry <code>data-widget-id</code> attributes — the annotation launcher
and reporting contract work identically for both.
</p>
<!-- ============================================================
NATIVE IHP WIDGET (simulated — normally rendered by IHP HSX)
============================================================ -->
<div class="card"
data-widget-id="00000000-0000-0000-0000-000000000001"
data-hub-id="00000000-0000-0000-0000-000000000099"
data-view-context="test-fixture"
data-policy-scope="internal"
data-widget-version="1">
<div class="label">Native IHP widget <span class="badge badge-native">IHP</span></div>
<h2>System Health Panel</h2>
<p style="font-size:0.85rem;color:#374151;margin:0">
This div was emitted by <code>widgetEnvelope</code> in Haskell HSX.
All <code>data-*</code> attributes are present per EnvelopeEmissionContract v1.0.
</p>
</div>
<!-- ============================================================
REACT WIDGET — mounted by the script below
============================================================ -->
<div id="react-widget-root"></div>
<!-- ============================================================
EVENT LOG
============================================================ -->
<div class="card" style="max-width:480px">
<h2>Interaction Event Log</h2>
<pre id="event-log">No events yet.</pre>
</div>
<!--
In a real integration React would come from your bundle.
Here we load it from a CDN for the standalone test.
-->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script type="module">
import { useWidgetEnvelope, useInteractionReporter } from '/js/ihf-react-adapter.js';
const { createElement: h, useState } = React;
const WIDGET_ID = '00000000-0000-0000-0000-000000000002';
const HUB_ID = '00000000-0000-0000-0000-000000000099';
const API_KEY = 'test-key'; // replace with real hub api_key in integration
function log(msg) {
var el = document.getElementById('event-log');
var ts = new Date().toISOString().substr(11, 12);
el.textContent = '[' + ts + '] ' + msg + '\n' + el.textContent;
}
function ReactMetricsWidget() {
const [count, setCount] = useState(0);
const { envelopeProps } = useWidgetEnvelope(WIDGET_ID, HUB_ID, 'test-fixture');
const { reportEvent } = useInteractionReporter(WIDGET_ID, HUB_ID, API_KEY);
async function handleClick() {
setCount(c => c + 1);
try {
await reportEvent('clicked');
log('reportEvent("clicked") → success');
} catch (e) {
log('reportEvent("clicked") → ' + e.message);
}
}
return h('div', Object.assign({ className: 'card' }, envelopeProps),
h('div', { className: 'label' },
'React widget',
h('span', { className: 'badge badge-react' }, 'React'),
),
h('h2', null, 'Live Metrics Panel'),
h('p', { style: { fontSize: '0.85rem', color: '#374151', margin: '0 0 12px' } },
'This component uses useWidgetEnvelope + useInteractionReporter.',
h('br', null),
'Button clicks are 14reported to /api/v1/interaction-events.'
),
h('button', { onClick: handleClick }, 'Click me (clicked × ' + count + ')'),
h('pre', { style: { marginTop: '10px' } },
JSON.stringify(envelopeProps, null, 2)
),
);
}
ReactDOM.createRoot(document.getElementById('react-widget-root'))
.render(h(ReactMetricsWidget));
log('React widget mounted — widget_id=' + WIDGET_ID);
</script>
<!-- Annotation launcher — normally injected by IHP when IHP_ANNOTATION_LAUNCHER=true -->
<script src="/js/ihf-annotation-launcher.js"></script>
</body>
</html>

View File

@@ -0,0 +1,204 @@
/**
* IHF React Adapter — ihf-react-adapter.js
*
* A thin React hook + HOC that makes React components participate in the IHF
* widget protocol without coupling to the IHP server.
*
* Conforms to:
* - EnvelopeEmissionContract v1.0 (data-widget-id, data-view-context, data-hub-id)
* - InteractionReportingContract v1.0 (POST /api/v1/interaction-events, Bearer auth)
*
* This file is a plain ESM module. Consumers bundle it themselves — no build
* toolchain is added to the IHP project.
*
* Usage:
*
* import {
* useWidgetEnvelope,
* withWidgetEnvelope,
* useInteractionReporter,
* } from '/js/ihf-react-adapter.js';
*
* Requires React 16.8+ (hooks). Tested with React 18.
*
* See docs/react-adapter.md for full examples.
*/
// ---------------------------------------------------------------------------
// Imports (React from a CDN/esm.sh or bundled — resolved by the consumer)
// ---------------------------------------------------------------------------
// We avoid a hard React import at the top of this file so the module works
// both as a bundled ESM and when React is available as a global.
//
// The hooks are exported as factory functions that accept React as a parameter
// when used in non-module contexts, but are also available directly when
// React is importable from the consumer's bundle.
// ---------------------------------------------------------------------------
// useWidgetEnvelope
// ---------------------------------------------------------------------------
/**
* React hook that returns `data-*` props and a `ref` conforming to
* EnvelopeEmissionContract v1.0.
*
* @param {string} widgetId - UUID from the widget registry
* @param {string} hubId - UUID of the owning hub
* @param {string} viewContext - Logical UI location (e.g. "main-dashboard")
* @param {object} [options] - Optional extra attributes
* @param {string} [options.policyScope] - "internal" | "hub" | "public"
* @param {string} [options.widgetVersion] - Widget version string
* @returns {{ ref: object, envelopeProps: object }}
*
* @example
* function MyWidget({ widgetId, hubId }) {
* const { ref, envelopeProps } = useWidgetEnvelope(widgetId, hubId, 'main');
* return <div ref={ref} {...envelopeProps}><button>Click me</button></div>;
* }
*/
export function useWidgetEnvelope(widgetId, hubId, viewContext, options) {
var opts = options || {};
// Validate required attributes (contract v1.0 — warn, do not throw).
if (!widgetId) {
console.warn('[IHF] useWidgetEnvelope: widgetId is required (EnvelopeEmissionContract v1.0)');
}
if (!hubId) {
console.warn('[IHF] useWidgetEnvelope: hubId is required (EnvelopeEmissionContract v1.0)');
}
if (!viewContext) {
console.warn('[IHF] useWidgetEnvelope: viewContext is required (EnvelopeEmissionContract v1.0)');
}
var envelopeProps = {
'data-widget-id': widgetId || '',
'data-hub-id': hubId || '',
'data-view-context': viewContext || '',
};
if (opts.policyScope) {
envelopeProps['data-policy-scope'] = opts.policyScope;
}
if (opts.widgetVersion) {
envelopeProps['data-widget-version'] = opts.widgetVersion;
}
// Return a plain ref object — compatible with useRef() but does not require
// React to be in scope at module evaluation time.
var ref = { current: null };
return { ref: ref, envelopeProps: envelopeProps };
}
// ---------------------------------------------------------------------------
// withWidgetEnvelope
// ---------------------------------------------------------------------------
/**
* Higher-order component that applies the IHF widget envelope to the root
* DOM element of the wrapped component.
*
* @param {React.ComponentType} WrappedComponent
* @param {string} widgetId
* @param {string} hubId
* @param {string} viewContext
* @param {object} [options]
* @returns {React.ComponentType}
*
* @example
* const GovernedMetricsPanel = withWidgetEnvelope(
* MetricsPanel,
* 'a3f1c...', // widgetId
* 'b9e2d...', // hubId
* 'ops-dashboard'
* );
*/
export function withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext, options) {
var envelope = useWidgetEnvelope(widgetId, hubId, viewContext, options);
function EnvelopedComponent(props) {
// We inject envelope props onto a wrapper div so the inner component is
// not modified. The annotation launcher will find data-widget-id on the
// wrapper and inject its trigger there.
return {
type: 'div',
props: Object.assign({}, envelope.envelopeProps, {
children: { type: WrappedComponent, props: props },
}),
};
}
EnvelopedComponent.displayName =
'WithWidgetEnvelope(' + (WrappedComponent.displayName || WrappedComponent.name || 'Component') + ')';
return EnvelopedComponent;
}
// ---------------------------------------------------------------------------
// useInteractionReporter
// ---------------------------------------------------------------------------
/**
* Returns a `reportEvent(eventType)` function that POSTs to the IHF
* interaction reporting endpoint (InteractionReportingContract v1.0).
*
* @param {string} widgetId - UUID from the widget registry
* @param {string} hubId - UUID of the owning hub
* @param {string} apiKey - Hub API key for Bearer auth
* @returns {{ reportEvent: function }}
*
* @example
* function MyButton({ widgetId, hubId, apiKey }) {
* const { reportEvent } = useInteractionReporter(widgetId, hubId, apiKey);
* return (
* <button onClick={() => reportEvent('clicked')}>
* Click me
* </button>
* );
* }
*/
export function useInteractionReporter(widgetId, hubId, apiKey) {
var endpoint = '/api/v1/interaction-events';
/**
* Report an interaction event.
*
* @param {string} eventType - One of: clicked, viewed, submitted, dismissed, errored
* @returns {Promise<object>} Resolves with the created event record.
*/
function reportEvent(eventType) {
var validTypes = ['clicked', 'viewed', 'submitted', 'dismissed', 'errored'];
if (validTypes.indexOf(eventType) === -1) {
console.warn('[IHF] useInteractionReporter: unrecognised event_type "' + eventType + '". Accepted: ' + validTypes.join(', '));
}
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (apiKey || ''),
},
body: JSON.stringify({
widget_id: widgetId,
hub_id: hubId,
event_type: eventType,
occurred_at: new Date().toISOString(),
}),
})
.then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (body) {
var msg = (body && body.error) ? body.error : 'HTTP ' + res.status;
throw new Error('[IHF] reportEvent failed: ' + msg);
});
}
return res.json();
})
.catch(function (err) {
console.error('[IHF] reportEvent error:', err);
throw err;
});
}
return { reportEvent: reportEvent };
}

View File

@@ -219,7 +219,7 @@ form allows adapter assignment; widget show page renders adapter badge.
```task
id: IHUB-WP-0006-T05
status: todo
status: done
priority: medium
state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf"
```