]*)>([\s\S]*)<\/div>$/);
+ if (!rootMatch) {
+ return html;
+ }
+
+ const [, attrsString, childrenHtml] = rootMatch;
+ const attrs = parseAttributes(attrsString);
+ attrs.set('data-djid', '__DJID__');
+ const rootHtml = `
${childrenHtml}
`;
+ const scriptHtml =
+ `';
+ return `${rootHtml}${scriptHtml}`;
+}
+
+function normalizeRenderedHtml(component, testCase, html, allowedPrefixes) {
+ const normalized = normalizeDomAttributes(html, testCase, allowedPrefixes);
+ if (component === 'button_group' && normalized) {
+ return injectButtonGroupRuntime(normalized);
+ }
+ return normalized;
+}
+
+async function generateFixtureFromModule(modulePath, runtime) {
+ const caseModule = await import(new URL(modulePath, import.meta.url));
+ const { component, cases, class_prefixes: classPrefixes = [] } = caseModule;
+ const allowedPrefixes = new Set(classPrefixes);
+ const parityComponents = await loadParityComponents(component);
+
+ if (!component || !Array.isArray(cases)) {
+ throw new Error(`Invalid parity case module at ${modulePath}`);
+ }
+
+ const serializedCases = [];
+ for (const testCase of cases) {
+ const { html, warnings } = captureWarnings(() =>
+ runtime.renderToStaticMarkup(
+ testCase.buildElement({
+ React: runtime.React,
+ components: parityComponents,
+ })
+ )
+ );
+ const normalizedHtml = normalizeRenderedHtml(component, testCase, html, allowedPrefixes);
+ serializedCases.push({
+ name: testCase.name,
+ template: testCase.template,
+ expected_html: normalizedHtml,
+ expected_warnings: warnings,
+ meta: testCase.meta ?? {},
+ });
+ }
+
+ const fixturePath = path.resolve(__dirname, '../tests/fixtures', `ui_html_${component}_parity.json`);
+ const generatedAtUtc =
+ process.env[GENERATED_AT_ENV_VAR] ?? (await loadExistingGeneratedAtUtc(fixturePath)) ?? new Date().toISOString();
+
+ const fixture = {
+ generated_by: 'scripts/generateHtmlUiParityFixtures.mjs',
+ generated_at_utc: generatedAtUtc,
+ component,
+ styles: {},
+ cases: serializedCases,
+ };
+
+ const serializedFixture = `${JSON.stringify(fixture, null, 2)}\n`;
+ const formattedFixture = await formatFixtureJson(serializedFixture);
+ await fs.writeFile(fixturePath, formattedFixture, 'utf8');
+ return fixturePath;
+}
+
+async function main() {
+ const runtime = await loadRendererRuntime();
+ const onlyComponent = process.argv[2];
+ const modulePaths = CASE_MODULES;
+ for (const modulePath of modulePaths) {
+ const caseModule = await import(new URL(modulePath, import.meta.url));
+ if (onlyComponent && caseModule.component !== onlyComponent) {
+ continue;
+ }
+ const fixturePath = await generateFixtureFromModule(modulePath, runtime);
+ process.stdout.write(`Wrote ${fixturePath}\n`);
+ }
+}
+
+await main();
diff --git a/packages/ap-ui/scripts/parity_cases/button.mjs b/packages/ap-ui/scripts/parity_cases/button.mjs
new file mode 100644
index 00000000..6000eda1
--- /dev/null
+++ b/packages/ap-ui/scripts/parity_cases/button.mjs
@@ -0,0 +1,41 @@
+export const component = 'button';
+export const class_prefixes = ['focusRing', 'Button'];
+
+export const cases = [
+ {
+ name: 'default',
+ template: '{% ui "button" %}Save{% endui %}',
+ buildElement({ React, components }) {
+ const { Button } = components;
+ return React.createElement(Button, null, 'Save');
+ },
+ meta: {},
+ },
+ {
+ name: 'invalid_variant_warns_and_falls_back',
+ template: '{% ui "button" variant="invalid" %}Save{% endui %}',
+ buildElement({ React, components }) {
+ const { Button } = components;
+ return React.createElement(Button, { variant: 'invalid' }, 'Save');
+ },
+ meta: {},
+ },
+ {
+ name: 'anchor_variant',
+ template: '{% ui "button" href="/next" color="secondary" size="lg" %}Go{% endui %}',
+ buildElement({ React, components }) {
+ const { Button } = components;
+ return React.createElement(Button, { href: '/next', color: 'secondary', size: 'lg' }, 'Go');
+ },
+ meta: {},
+ },
+ {
+ name: 'icon_only',
+ template: '{% ui "button" %}
{% endui %}',
+ buildElement({ React, components }) {
+ const { Button } = components;
+ return React.createElement(Button, null, React.createElement('span', { 'data-apui-slot': 'icon' }));
+ },
+ meta: {},
+ },
+];
diff --git a/packages/ap-ui/scripts/parity_cases/button_group.mjs b/packages/ap-ui/scripts/parity_cases/button_group.mjs
new file mode 100644
index 00000000..c6f3d7ba
--- /dev/null
+++ b/packages/ap-ui/scripts/parity_cases/button_group.mjs
@@ -0,0 +1,41 @@
+export const component = 'button_group';
+export const class_prefixes = ['SmartOrientation', 'ButtonGroup', 'focusRing', 'Button'];
+
+export const cases = [
+ {
+ name: 'default',
+ template: '{% ui "button_group" %}{% ui "button" %}One{% endui %}{% endui %}',
+ buildElement({ React, components }) {
+ const { Button, ButtonGroup } = components;
+ return React.createElement(
+ ButtonGroup,
+ null,
+ React.createElement(Button, null, 'One')
+ );
+ },
+ meta: {},
+ },
+ {
+ name: 'slot_defaults_and_child_class_merge',
+ template:
+ '{% ui "button_group" variant="outlined" color="gray" size="lg" density="compact" align="end" %}{% ui "button" className="custom" %}Two{% endui %}{% endui %}',
+ buildElement({ React, components }) {
+ const { Button, ButtonGroup } = components;
+ return React.createElement(
+ ButtonGroup,
+ { variant: 'outlined', color: 'gray', size: 'lg', density: 'compact', align: 'end' },
+ React.createElement(Button, { className: 'custom' }, 'Two')
+ );
+ },
+ meta: {},
+ },
+ {
+ name: 'empty_children',
+ template: '{% ui "button_group" %}{% endui %}',
+ buildElement({ React, components }) {
+ const { ButtonGroup } = components;
+ return React.createElement(ButtonGroup, null);
+ },
+ meta: {},
+ },
+];
diff --git a/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh b/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh
new file mode 100755
index 00000000..1e7771fd
--- /dev/null
+++ b/packages/ap-ui/scripts/syncHtmlUiParityFixtures.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
+AP_UI_DIR="$(cd -- "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)"
+PY_REPO_ROOT="$(cd -- "${AP_UI_DIR}/../.." >/dev/null 2>&1 && pwd)"
+
+DEFAULT_JS_REPO="${PY_REPO_ROOT}/../alliance-platform-js"
+JS_REPO="${ALLIANCE_PLATFORM_JS_DIR:-${DEFAULT_JS_REPO}}"
+COMPONENT=""
+
+usage() {
+ cat <<'EOF'
+Usage: syncHtmlUiParityFixtures.sh [--js-repo
] [--component ]
+
+Regenerates ap-ui HTML parity fixtures using the JS workspace runtime.
+
+Options:
+ --js-repo Path to alliance-platform-js (default: ../alliance-platform-js)
+ --component Generate only one component fixture (for example "button")
+ -h, --help Show this help
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --js-repo)
+ if [[ $# -lt 2 ]]; then
+ echo "Missing value for --js-repo" >&2
+ usage >&2
+ exit 1
+ fi
+ JS_REPO="$2"
+ shift 2
+ ;;
+ --component)
+ if [[ $# -lt 2 ]]; then
+ echo "Missing value for --component" >&2
+ usage >&2
+ exit 1
+ fi
+ COMPONENT="$2"
+ shift 2
+ ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ ! -d "${JS_REPO}" ]]; then
+ echo "alliance-platform-js directory not found: ${JS_REPO}" >&2
+ exit 1
+fi
+JS_REPO="$(cd -- "${JS_REPO}" >/dev/null 2>&1 && pwd)"
+
+UI_PACKAGE_DIR="${JS_REPO}/packages/ui"
+if [[ ! -d "${UI_PACKAGE_DIR}" ]]; then
+ echo "Expected UI package directory not found: ${UI_PACKAGE_DIR}" >&2
+ exit 1
+fi
+
+VITE_NODE_BIN="${JS_REPO}/node_modules/.bin/vite-node"
+if [[ ! -x "${VITE_NODE_BIN}" ]]; then
+ cat >&2 < ",
+ "expected_warnings": [],
+ "meta": {}
+ },
+ {
+ "name": "slot_defaults_and_child_class_merge",
+ "template": "{% ui \"button_group\" variant=\"outlined\" color=\"gray\" size=\"lg\" density=\"compact\" align=\"end\" %}{% ui \"button\" className=\"custom\" %}Two{% endui %}{% endui %}",
+ "expected_html": "