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

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>