From 539134234287bd7f8d9223dfcb98596ce40cce43 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 29 Aug 2026 16:17:14 -0400 Subject: [PATCH 1/6] feat: add Fix workspace foundation --- pyproject.toml | 2 +- src/attune_forms/__init__.py | 27 +++ src/attune_forms/markdown_surface.py | 25 ++- src/attune_forms/semantic_tokens.json | 55 +++++ src/attune_forms/theme.py | 108 +++++++++- src/attune_forms/tokens.py | 39 ++++ src/attune_forms/widget.py | 14 +- src/attune_forms/workspace.py | 298 ++++++++++++++++++++++++++ tests/fixtures/__init__.py | 1 + tests/fixtures/workspace_showcase.py | 119 ++++++++++ tests/test_form_theme.py | 22 ++ tests/test_workspace.py | 115 ++++++++++ 12 files changed, 807 insertions(+), 18 deletions(-) create mode 100644 src/attune_forms/semantic_tokens.json create mode 100644 src/attune_forms/tokens.py create mode 100644 src/attune_forms/workspace.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/workspace_showcase.py create mode 100644 tests/test_workspace.py diff --git a/pyproject.toml b/pyproject.toml index 8918489..b7d1abc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ Repository = "https://github.com/Smart-AI-Memory/attune-forms" where = ["src"] [tool.setuptools.package-data] -attune_forms = ["templates/*.json"] +attune_forms = ["templates/*.json", "semantic_tokens.json"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/attune_forms/__init__.py b/src/attune_forms/__init__.py index 865a1fb..b7dd948 100644 --- a/src/attune_forms/__init__.py +++ b/src/attune_forms/__init__.py @@ -83,7 +83,21 @@ ) from attune_forms.reference_form import EXAMPLE_ANSWERS, REFERENCE_FORM from attune_forms.template_store import form_from_template, list_templates +from attune_forms.tokens import SEMANTIC_TOKENS, token from attune_forms.widget import WIDGET_RESPONSE_MARKER, form_to_widget_html +from attune_forms.workspace import ( + WorkspaceAction, + WorkspaceActionIntent, + WorkspaceBlock, + WorkspaceBlockKind, + WorkspaceItem, + WorkspaceSection, + WorkspaceTone, + WorkspaceView, + WorkspaceViewId, + workspace_to_markdown, + workspace_to_widget_html, +) __all__ = [ "EXAMPLE_ANSWERS", @@ -103,6 +117,7 @@ "validate_template", "REFERENCE_FORM", "WIDGET_RESPONSE_MARKER", + "SEMANTIC_TOKENS", "FormValidationError", "collect_form_response", "form_from_dict", @@ -122,6 +137,18 @@ "problems_to_markdown", "select_form_surface", "set_keyboard_mode", + "token", + "WorkspaceAction", + "WorkspaceActionIntent", + "WorkspaceBlock", + "WorkspaceBlockKind", + "WorkspaceItem", + "WorkspaceSection", + "WorkspaceTone", + "WorkspaceView", + "WorkspaceViewId", + "workspace_to_markdown", + "workspace_to_widget_html", "ASSUMPTION_RULINGS", "ranking_slot_count", "triage_item_key", diff --git a/src/attune_forms/markdown_surface.py b/src/attune_forms/markdown_surface.py index be75a4f..995ef3b 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -322,7 +322,11 @@ def _skeleton_value(q: FormQuestion) -> Any: return q.recommended if q.recommended else None -def reply_skeleton(form: FormSchema, questions: list[FormQuestion] | None = None) -> dict[str, Any]: +def reply_skeleton( + form: FormSchema, + questions: list[FormQuestion] | None = None, + action: str | None = None, +) -> dict[str, Any]: """The sentinel-marked reply skeleton for a form (or a subset of it). ``questions`` restricts the ``answers`` map to those fields — the @@ -333,14 +337,22 @@ def reply_skeleton(form: FormSchema, questions: list[FormQuestion] | None = None ingested as a reply (confirmation pass 2, 2026-08-20). """ chosen = questions if questions is not None else form.questions - return { + payload = { WIDGET_RESPONSE_MARKER: True, "title": form.title, "answers": {q.id: _skeleton_value(q) for q in chosen}, } + if action is not None: + payload["action"] = action + return payload -def form_to_markdown(form: FormSchema, message: str = "") -> str: +def form_to_markdown( + form: FormSchema, + message: str = "", + action: str | None = None, + submit_label: str | None = None, +) -> str: """Render a declarative form as portable markdown (S4). For hosts that render neither HTML widgets nor a question tool: the @@ -354,6 +366,9 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str: form: The validated form to render (build it with :func:`form_from_dict` first). message: Optional prompt shown above the form. + action: Optional stable host action id added to the answer + skeleton. + submit_label: Optional action-specific instruction label. Returns: A markdown string ready to relay to any text host. @@ -373,13 +388,13 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str: lines += [ "", "---", - "Reply by filling the `answers` values below, or with shorthand " + f"{submit_label or 'Reply'} by filling the `answers` values below, or with shorthand " "lines — `field_id: value` or `N: value` (field number); a triage " "row is `field_id.item_id: disposition`; a ranking is a comma list " "in order (`field_id: b, a, c`) or one slot per line " "(`field_id.1: b`); an assumption row is `field_id.item_id: accept`, " "`field_id.item_id: reject`, or `field_id.item_id: edit: `:", "", - *_skeleton_block(reply_skeleton(form)), + *_skeleton_block(reply_skeleton(form, action=action)), ] return "\n".join(lines) diff --git a/src/attune_forms/semantic_tokens.json b/src/attune_forms/semantic_tokens.json new file mode 100644 index 0000000..5b30a51 --- /dev/null +++ b/src/attune_forms/semantic_tokens.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "color": { + "light": { + "action": "#004ac6", + "action_hover": "#003ea8", + "success": "#006c49", + "warning": "#a1571c", + "danger": "#ba1a1a", + "recommendation": "#7c3aed", + "neutral_text": "#0b1c30", + "neutral_muted": "#5f6470", + "surface": "#f8f9ff", + "surface_raised": "#eff4ff", + "border": "#c3c6d7", + "focus": "#2563eb" + }, + "dark": { + "action": "#8db2ff", + "action_hover": "#b4c9ff", + "success": "#4edea3", + "warning": "#ffb77d", + "danger": "#ffb4ab", + "recommendation": "#c5b4ff", + "neutral_text": "#f8f9ff", + "neutral_muted": "#b7c8e1", + "surface": "#0b1c30", + "surface_raised": "#1a2d42", + "border": "#38485d", + "focus": "#b4c9ff" + } + }, + "radius": { + "control": "8px", + "panel": "12px" + }, + "spacing": { + "xs": "0.25rem", + "sm": "0.5rem", + "md": "1rem", + "lg": "1.5rem" + }, + "motion": { + "fast": "120ms", + "normal": "200ms" + }, + "control": { + "minimum_target": "2.5rem" + }, + "typography": { + "body": "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + "heading": "Manrope, Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + "mono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" + } +} diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index d17de8e..8290c55 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -38,13 +38,42 @@ from __future__ import annotations +from attune_forms.tokens import token + +CSS_SEMANTIC_TOKENS = ( + "#attune-elicit-form {\n" + f" --ae-action:var(--primary,{token('color.light.action')}); " + f"--ae-action-hover:var(--primary-dark,{token('color.light.action_hover')});\n" + f" --ae-success:var(--text-success,{token('color.light.success')}); " + f"--ae-warning:var(--text-accent,{token('color.light.warning')});\n" + f" --ae-danger:var(--text-danger,{token('color.light.danger')}); " + f"--ae-recommendation:var(--accent,{token('color.light.recommendation')});\n" + f" --ae-text:var(--text-primary,{token('color.light.neutral_text')}); " + f"--ae-muted:var(--text-muted,{token('color.light.neutral_muted')});\n" + f" --ae-surface:var(--surface-1,{token('color.light.surface')}); " + f"--ae-surface-raised:var(--surface-2,{token('color.light.surface_raised')});\n" + f" --ae-border:var(--border,{token('color.light.border')}); " + f"--ae-focus:var(--focus-ring,{token('color.light.focus')});\n" + f" --ae-radius-control:{token('radius.control')}; " + f"--ae-radius-panel:{token('radius.panel')};\n" + f" --ae-motion-fast:{token('motion.fast')}; " + f"--ae-motion-normal:{token('motion.normal')}; " + f"--ae-target-min:{token('control.minimum_target')};\n" + f" --ae-font-body:{token('typography.body')}; " + f"--ae-font-heading:{token('typography.heading')}; " + f"--ae-font-mono:{token('typography.mono')}; }}\n" +) + #: Base rules every form emits (scoped under ``#attune-elicit-form``; #: the widget renderer rewrites the id per instance). -CSS_BASE = """#attune-elicit-form { display:block; width:100%; padding:1rem 0; - color:var(--text-primary,#2c2c2a); line-height:1.5; } +CSS_BASE = ( + CSS_SEMANTIC_TOKENS + + """#attune-elicit-form { display:block; width:100%; padding:1rem 0; + color:var(--ae-text,#0b1c30); line-height:1.5; font-family:var(--ae-font-body,system-ui); } #attune-elicit-form .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); } -#attune-elicit-form h3 { font-size:18px; font-weight:500; margin:0 0 .25rem; } +#attune-elicit-form h3 { font-family:var(--ae-font-heading,system-ui); font-size:18px; + font-weight:650; letter-spacing:-.015em; margin:0 0 .25rem; } #attune-elicit-form .ae-msg { margin:0 0 .5rem; color:var(--text-secondary,#5f5e59); } #attune-elicit-form .ae-desc { margin:0 0 1rem; color:var(--text-muted,#8a887f); font-size:15px; } @@ -65,22 +94,31 @@ #attune-elicit-form .ae-help { font-size:13px; color:var(--text-muted,#8a887f); margin:0 0 .35rem; } #attune-elicit-form .ae-submit { margin-top:.5rem; padding:.55rem 1.1rem; - font-size:15px; font-weight:500; cursor:pointer; color:var(--text-primary,#2c2c2a); - background:var(--bg-accent,#f3ece4); border:1px solid var(--border-accent,#d8b89a); - border-radius:var(--radius,8px); } + min-height:var(--ae-target-min,2.5rem); font-size:15px; font-weight:600; cursor:pointer; + color:#fff; background:var(--ae-action,#004ac6); border:1px solid var(--ae-action,#004ac6); + border-radius:var(--ae-radius-control,8px); transition:background var(--ae-motion-fast,120ms); } +#attune-elicit-form .ae-submit:hover { background:var(--ae-action-hover,#003ea8); } #attune-elicit-form .ae-submit:disabled { opacity:.6; cursor:default; } #attune-elicit-form .ae-error { margin-top:.5rem; font-size:14px; color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-field-missing { border-left:3px solid - var(--text-accent,#a1571c); padding-left:.6rem; } + var(--ae-danger,#ba1a1a); padding-left:.6rem; } +#attune-elicit-form :is(input,select,textarea,button):focus-visible { + outline:3px solid var(--ae-focus,#2563eb); outline-offset:2px; } +#attune-elicit-form :is(input,select,textarea,button):disabled { opacity:.6; cursor:not-allowed; } #attune-elicit-form .ae-detail-block { flex-basis:100%; white-space:pre-wrap; - font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + font-family:var(--ae-font-mono,ui-monospace); overflow-x:auto; } +@media (prefers-reduced-motion:reduce) { #attune-elicit-form * { + scroll-behavior:auto!important; transition-duration:0ms!important; + animation-duration:0ms!important; } } """ +) #: INPUT — text_input, textarea, number, date, boolean, non-list single_select. CSS_INPUT = """#attune-elicit-form .ae-input { width:100%; box-sizing:border-box; - padding:.5rem .6rem; font-size:15px; color:var(--text-primary,#2c2c2a); + min-height:var(--ae-target-min,2.5rem); padding:.5rem .6rem; + font-size:15px; color:var(--text-primary,#2c2c2a); background:var(--surface-1,#f7f6f3); border:1px solid var(--border,#e3e1dc); border-radius:var(--radius,8px); } #attune-elicit-form .ae-textarea { resize:vertical; min-height:3.5rem; } @@ -108,7 +146,9 @@ border:1px solid var(--border,#e3e1dc); border-radius:var(--radius,8px); cursor:pointer; } #attune-elicit-form .ae-card:hover { border-color:var(--text-muted,#8a887f); } -#attune-elicit-form .ae-card-rec { border-color:var(--border-accent,#d8b89a); } +#attune-elicit-form .ae-card-rec { border-color:var(--ae-recommendation,#7c3aed); } +#attune-elicit-form .ae-card:has(input:checked) { border-color:var(--ae-action,#004ac6); + background:var(--ae-surface-raised,#eff4ff); } #attune-elicit-form .ae-card input { position:absolute; top:.7rem; right:.6rem; } #attune-elicit-form .ae-card-title { font-weight:500; } #attune-elicit-form .ae-card-note { font-size:13px; color:var(--text-muted,#8a887f); } @@ -243,3 +283,51 @@ #: string is what the ops dashboard serves at ``/static/form-theme.css`` #: (byte-equal by drift test) and what the 4 KB budget test measures. FORM_THEME_CSS = CSS_BASE + "".join(css for _name, css in CSS_FAMILIES) + +#: Workspace chrome is separate from ``FORM_THEME_CSS``: ordinary forms +#: never pay for command-workspace styles. It shares the exact semantic +#: token source and remains scoped/rewriteable like the form sheet. +CSS_WORKSPACE = ( + CSS_SEMANTIC_TOKENS.replace("#attune-elicit-form", "#attune-workspace") + + """#attune-workspace { color:var(--ae-text,#0b1c30); + font-family:var(--ae-font-body,system-ui); line-height:1.5; } +#attune-workspace .ae-ws-head { margin-bottom:var(--ae-space-md,1rem); } +#attune-workspace .ae-ws-title { font-family:var(--ae-font-heading,system-ui); + font-size:20px; font-weight:650; letter-spacing:-.015em; margin:0; } +#attune-workspace .ae-ws-summary { color:var(--ae-muted,#5f6470); margin:.25rem 0 0; } +#attune-workspace .ae-ws-section { border-top:1px solid var(--ae-border,#c3c6d7); + padding:1rem 0; } +#attune-workspace .ae-ws-section:first-of-type { border-top:0; } +#attune-workspace .ae-ws-section h4 { margin:0 0 .5rem; font-size:14px; font-weight:650; } +#attune-workspace .ae-ws-action { min-height:var(--ae-target-min,2.5rem); + padding:.55rem 1rem; border-radius:var(--ae-radius-control,8px); cursor:pointer; + border:1px solid var(--ae-border,#c3c6d7); background:transparent; + color:var(--ae-text,#0b1c30); font-weight:600; } +#attune-workspace .ae-ws-action-primary { color:#fff; + background:var(--ae-action,#004ac6); border-color:var(--ae-action,#004ac6); } +#attune-workspace .ae-ws-action-danger { color:var(--ae-danger,#ba1a1a); + border-color:var(--ae-danger,#ba1a1a); } +#attune-workspace .ae-ws-actions { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; } +#attune-workspace :is(button,summary):focus-visible { outline:3px solid + var(--ae-focus,#2563eb); outline-offset:2px; } +#attune-workspace .ae-ws-kv { display:grid; grid-template-columns:minmax(7rem,auto) 1fr; + gap:.35rem 1rem; margin:0; } +#attune-workspace .ae-ws-kv dt { color:var(--ae-muted,#5f6470); } +#attune-workspace .ae-ws-kv dd { margin:0; } +#attune-workspace .ae-ws-code { white-space:pre-wrap; overflow-x:auto; + background:var(--ae-surface-raised,#eff4ff); border-radius:var(--ae-radius-control,8px); + padding:.75rem; font-family:var(--ae-font-mono,ui-monospace); } +#attune-workspace .ae-ws-list { display:grid; gap:.4rem; padding-left:1.25rem; } +#attune-workspace .ae-ws-status { font-size:11px; font-weight:650; + text-transform:uppercase; letter-spacing:.04em; color:var(--ae-muted,#5f6470); } +#attune-workspace .ae-ws-evidence { width:100%; border-collapse:collapse; font-size:14px; } +#attune-workspace .ae-ws-evidence :is(th,td) { padding:.4rem; text-align:left; + border-bottom:1px solid var(--ae-border,#c3c6d7); } +#attune-workspace details { border:1px solid var(--ae-border,#c3c6d7); + border-radius:var(--ae-radius-control,8px); padding:.5rem .75rem; } +@media (max-width:32rem) { #attune-workspace .ae-ws-kv { grid-template-columns:1fr; } + #attune-workspace .ae-ws-kv dd { margin-bottom:.4rem; } } +@media (prefers-reduced-motion:reduce) { #attune-workspace * { + transition-duration:0ms!important; animation-duration:0ms!important; } } +""" +) diff --git a/src/attune_forms/tokens.py b/src/attune_forms/tokens.py new file mode 100644 index 0000000..b934200 --- /dev/null +++ b/src/attune_forms/tokens.py @@ -0,0 +1,39 @@ +"""Provider-neutral semantic design tokens. + +The JSON artifact is the cross-repository source consumed by +``attune-forms`` and projected into host applications. Python loads it +through package resources so wheels and editable installs behave the +same way. +""" + +from __future__ import annotations + +import json +from importlib.resources import files +from types import MappingProxyType +from typing import Any + + +def _load_tokens() -> dict[str, Any]: + source = files("attune_forms").joinpath("semantic_tokens.json") + data = json.loads(source.read_text(encoding="utf-8")) + if data.get("version") != 1: + raise ValueError("unsupported semantic token version") + return data + + +SEMANTIC_TOKENS = MappingProxyType(_load_tokens()) + + +def token(path: str) -> str: + """Return one scalar token from a dot-separated path. + + Raises: + KeyError: If the path is absent or resolves to a mapping. + """ + value: Any = SEMANTIC_TOKENS + for part in path.split("."): + value = value[part] + if isinstance(value, dict): + raise KeyError(f"token path resolves to a mapping: {path}") + return str(value) diff --git a/src/attune_forms/widget.py b/src/attune_forms/widget.py index 0d0f74c..6365cba 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -21,6 +21,7 @@ from __future__ import annotations +import json import time import uuid from collections.abc import Callable @@ -695,6 +696,8 @@ def form_to_widget_html( form: FormSchema, message: str = "", instance_id: str | None = None, + submit_label: str | None = None, + submit_action: str | None = None, ) -> str: """Render a declarative form as an inline ``show_widget`` HTML form. @@ -725,6 +728,10 @@ def form_to_widget_html( defaults to a fresh random one per call. Pass a fixed value only when a deterministic render is needed (tests, golden output). + submit_label: Optional action-specific button label. Existing + callers retain the inferred Confirm/Submit label. + submit_action: Optional stable host action id included beside + the validated answers in the postback. Returns: An HTML string ready to pass straight to @@ -750,7 +757,8 @@ def form_to_widget_html( if confirm else "" ) - submit_label = "Confirm" if confirm else "Submit" + button_label = submit_label or ("Confirm" if confirm else "Submit") + action_js = json.dumps(submit_action) html = f"""

{_esc(form.title)} — interactive form

@@ -759,7 +767,7 @@ def form_to_widget_html(

{_esc(form.title)}

{intro}{desc}{confirm_html} {fields} - + """ + return f'
{head}{sections}{content}{actions}{script}
' + + +def _block_markdown(block: WorkspaceBlock) -> list[str]: + if block.kind is WorkspaceBlockKind.KEY_VALUE: + return [f"- **{item.label}:** {item.value or item.detail}" for item in block.items] + if block.kind is WorkspaceBlockKind.CODE: + return [f"```{block.language}", block.body.replace("```", "` ` `"), "```"] + if block.kind is WorkspaceBlockKind.EVIDENCE: + rows = ["| Evidence | Result | Status |", "| --- | --- | --- |"] + rows.extend( + f"| {item.label} | {item.value} | {item.status or item.detail} |" + for item in block.items + ) + return rows + if block.kind is WorkspaceBlockKind.DISCLOSURE: + return [f"**{block.title}**", "", block.body] + return [ + f"- {f'[{item.status}] ' if item.status else ''}**{item.label}**" + f"{f': {item.value}' if item.value else ''}" + f"{f' — {item.detail}' if item.detail else ''}" + for item in block.items + ] + + +def workspace_to_markdown(view: WorkspaceView) -> str: + """Render a workspace view to the portable markdown surface.""" + lines = [f"## {view.title}"] + if view.summary: + lines += ["", view.summary] + for section in view.sections: + if section.heading: + lines += ["", f"### {section.heading}"] + for block in section.blocks: + lines += ["", *_block_markdown(block)] + if view.form is not None: + action = view.actions[0] + lines += ["", form_to_markdown(view.form, action=action.id, submit_label=action.label)] + elif view.actions: + lines += ["", "### Actions"] + lines.extend( + f"- `{action.id}` — {action.label}" + f"{f' — {action.consequence}' if action.consequence else ''}" + for action in view.actions + ) + return "\n".join(lines) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..5f9e62e --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Reusable rendered-surface fixtures.""" diff --git a/tests/fixtures/workspace_showcase.py b/tests/fixtures/workspace_showcase.py new file mode 100644 index 0000000..cd16962 --- /dev/null +++ b/tests/fixtures/workspace_showcase.py @@ -0,0 +1,119 @@ +"""The generated all-construct command-workspace showcase fixture.""" + +from __future__ import annotations + +from attune_forms import ( + WorkspaceAction, + WorkspaceActionIntent, + WorkspaceBlock, + WorkspaceBlockKind, + WorkspaceItem, + WorkspaceSection, + WorkspaceTone, + WorkspaceView, + WorkspaceViewId, + form_from_dict, +) +from attune_forms.reference_form import REFERENCE_FORM + + +def showcase_views() -> tuple[WorkspaceView, ...]: + """Return all four Fix views and every display-block kind.""" + intake = WorkspaceView( + id=WorkspaceViewId.INTAKE, + title="Fix", + summary="Define the repair contract.", + form=form_from_dict(REFERENCE_FORM), + actions=( + WorkspaceAction( + id="preview_fix", + label="Preview fix", + intent=WorkspaceActionIntent.PRIMARY, + ), + ), + ) + preview = WorkspaceView( + id=WorkspaceViewId.PREVIEW, + title="Review fix contract", + sections=( + WorkspaceSection( + heading="Contract", + tone=WorkspaceTone.ACTION, + blocks=( + WorkspaceBlock( + WorkspaceBlockKind.KEY_VALUE, + items=( + WorkspaceItem("Outcome", "Tests pass after the rename"), + WorkspaceItem("Scope", "src/attune/forms.py"), + ), + ), + WorkspaceBlock( + WorkspaceBlockKind.CODE, + body="attune fix 'Tests pass' --scope src/attune/forms.py", + language="bash", + ), + WorkspaceBlock( + WorkspaceBlockKind.DISCLOSURE, + title="Advanced settings", + body="Provider: default; timeout: default", + ), + ), + ), + ), + actions=( + WorkspaceAction( + id="run_fix", + label="Run Fix", + intent=WorkspaceActionIntent.PRIMARY, + consequence="Execute the previewed contract.", + requires_explicit_choice=True, + ), + WorkspaceAction(id="edit_contract", label="Back to edit"), + ), + ) + execution = WorkspaceView( + id=WorkspaceViewId.EXECUTION, + title="Fix in progress", + sections=( + WorkspaceSection( + heading="Progress", + blocks=( + WorkspaceBlock( + WorkspaceBlockKind.TIMELINE, + items=( + WorkspaceItem("Diagnose", status="done"), + WorkspaceItem("Plan", status="done"), + WorkspaceItem("Edit", status="in flight"), + WorkspaceItem("Verify", status="waiting"), + ), + ), + WorkspaceBlock( + WorkspaceBlockKind.ACTION_LIST, + items=(WorkspaceItem("Inspect current log", detail="On demand"),), + ), + ), + ), + ), + ) + receipt = WorkspaceView( + id=WorkspaceViewId.RECEIPT, + title="Fix receipt", + sections=( + WorkspaceSection( + heading="Changes", + tone=WorkspaceTone.SUCCESS, + blocks=( + WorkspaceBlock( + WorkspaceBlockKind.CHANGE_SUMMARY, + items=(WorkspaceItem("src/attune/forms.py", "+4 −2"),), + ), + WorkspaceBlock( + WorkspaceBlockKind.EVIDENCE, + items=(WorkspaceItem("pytest tests/test_forms.py", "0", status="passed"),), + ), + ), + ), + ), + actions=(WorkspaceAction(id="inspect_diff", label="Inspect attributed diff"),), + ) + return intake, preview, execution, receipt diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index 18facba..ead4d9a 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -51,6 +51,28 @@ def test_full_sheet_is_base_plus_all_families_in_order() -> None: assert theme.FORM_THEME_CSS == expected +def test_semantic_state_matrix_is_present() -> None: + css = theme.FORM_THEME_CSS + for state in ( + ":hover", + ":focus-visible", + ":disabled", + ":checked", + ".ae-field-missing", + "prefers-reduced-motion", + ): + assert state in css + for role in ( + "--ae-action", + "--ae-success", + "--ae-warning", + "--ae-danger", + "--ae-recommendation", + "--ae-focus", + ): + assert role in css + + #: A selector's STYLED class: the last ``.class`` token before the rule #: body (pseudo-classes stripped). ``.ae-card .ae-prog-tag`` styles #: ``ae-prog-tag``; ``.ae-card:hover`` styles ``ae-card``. diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..5e0cbe9 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,115 @@ +"""Fix-first command workspace model and renderer receipts.""" + +from __future__ import annotations + +import json + +import pytest + +from attune_forms import ( + WorkspaceAction, + WorkspaceActionIntent, + WorkspaceBlock, + WorkspaceBlockKind, + WorkspaceItem, + WorkspaceSection, + WorkspaceView, + WorkspaceViewId, + form_from_dict, + workspace_to_markdown, + workspace_to_widget_html, +) +from attune_forms.models import QuestionType +from attune_forms.tokens import SEMANTIC_TOKENS, token +from tests.fixtures.workspace_showcase import showcase_views + + +def test_semantic_token_artifact_is_versioned_and_scalar_lookup_works() -> None: + assert SEMANTIC_TOKENS["version"] == 1 + assert token("color.light.action") == "#004ac6" + with pytest.raises(KeyError, match="mapping"): + token("color.light") + + +def test_workspace_rejects_executable_shape_and_ambiguous_authority() -> None: + with pytest.raises(ValueError, match="action id"): + WorkspaceAction(id="alert(1)", label="Bad") + with pytest.raises(ValueError, match="consequence"): + WorkspaceAction(id="run", label="Run", requires_explicit_choice=True) + with pytest.raises(ValueError, match="at most one primary"): + WorkspaceView( + id=WorkspaceViewId.PREVIEW, + title="T", + actions=( + WorkspaceAction("a", "A", WorkspaceActionIntent.PRIMARY), + WorkspaceAction("b", "B", WorkspaceActionIntent.PRIMARY), + ), + ) + with pytest.raises(ValueError, match="code block requires body"): + WorkspaceBlock(WorkspaceBlockKind.CODE) + + +def test_form_view_requires_exactly_one_submit_action() -> None: + form = form_from_dict( + {"title": "T", "fields": [{"id": "x", "text": "X?", "type": "text_input"}]} + ) + with pytest.raises(ValueError, match="exactly one"): + WorkspaceView(id=WorkspaceViewId.INTAKE, title="T", form=form) + + +def test_showcase_covers_every_view_construct_and_display_block() -> None: + views = showcase_views() + assert {view.id for view in views} == set(WorkspaceViewId) + intake = views[0] + assert intake.form is not None + assert {q.type for q in intake.form.questions} == set(QuestionType) + kinds = {block.kind for view in views for section in view.sections for block in section.blocks} + assert kinds == set(WorkspaceBlockKind) + + +def test_widget_form_action_uses_specific_label_and_stable_id() -> None: + html = workspace_to_widget_html(showcase_views()[0], instance_id="showcase") + assert 'data-workspace-view="intake"' in html + assert ">Preview fix" in html + assert "payload.action = submitAction" in html + assert 'var submitAction = "preview_fix"' in html + assert "@import" not in html + + +def test_widget_display_actions_post_only_stable_action_id() -> None: + html = workspace_to_widget_html(showcase_views()[1], instance_id="preview") + assert 'data-workspace-action="run_fix" data-explicit="1"' in html + assert 'data-workspace-action="edit_contract"' in html + assert "action:b.getAttribute('data-workspace-action')" in html + assert "" not in html + assert "<script>" in html From 4b65535fc6d7f11f2f54e50c48349d2176339ab3 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 29 Aug 2026 16:20:55 -0400 Subject: [PATCH 2/6] fix: harden workspace render boundaries --- src/attune_forms/markdown_surface.py | 6 +++++- src/attune_forms/theme.py | 1 + src/attune_forms/workspace.py | 18 +++++++++++++++--- tests/test_form_theme.py | 1 + tests/test_workspace.py | 20 ++++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/attune_forms/markdown_surface.py b/src/attune_forms/markdown_surface.py index 995ef3b..92b08db 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -352,6 +352,7 @@ def form_to_markdown( message: str = "", action: str | None = None, submit_label: str | None = None, + include_title: bool = True, ) -> str: """Render a declarative form as portable markdown (S4). @@ -369,11 +370,14 @@ def form_to_markdown( action: Optional stable host action id added to the answer skeleton. submit_label: Optional action-specific instruction label. + include_title: Whether to emit the form's level-two heading. + Workspace renderers disable it because their shell already + owns the view heading. Returns: A markdown string ready to relay to any text host. """ - lines = [f"## {form.title}"] + lines = [f"## {form.title}"] if include_title else [] if message: lines += ["", message] if form.description: diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index 8290c55..b5c90dd 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -56,6 +56,7 @@ f"--ae-focus:var(--focus-ring,{token('color.light.focus')});\n" f" --ae-radius-control:{token('radius.control')}; " f"--ae-radius-panel:{token('radius.panel')};\n" + f" --ae-space-md:{token('spacing.md')};\n" f" --ae-motion-fast:{token('motion.fast')}; " f"--ae-motion-normal:{token('motion.normal')}; " f"--ae-target-min:{token('control.minimum_target')};\n" diff --git a/src/attune_forms/workspace.py b/src/attune_forms/workspace.py index 5dfcbd0..89e07cb 100644 --- a/src/attune_forms/workspace.py +++ b/src/attune_forms/workspace.py @@ -247,7 +247,7 @@ def action_button(action: WorkspaceAction) -> str: if(!b||!root.contains(b))return; var payload={{{json.dumps(WIDGET_RESPONSE_MARKER)}:true, title:{json.dumps(view.title)},action:b.getAttribute('data-workspace-action')}}; - sendPrompt(JSON.stringify(payload)); + if(typeof sendPrompt==='function'){{sendPrompt(JSON.stringify(payload));}} }}); }})();""" return f'
{head}{sections}{content}{actions}{script}
' @@ -259,9 +259,13 @@ def _block_markdown(block: WorkspaceBlock) -> list[str]: if block.kind is WorkspaceBlockKind.CODE: return [f"```{block.language}", block.body.replace("```", "` ` `"), "```"] if block.kind is WorkspaceBlockKind.EVIDENCE: + + def cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", "
") + rows = ["| Evidence | Result | Status |", "| --- | --- | --- |"] rows.extend( - f"| {item.label} | {item.value} | {item.status or item.detail} |" + f"| {cell(item.label)} | {cell(item.value)} | " f"{cell(item.status or item.detail)} |" for item in block.items ) return rows @@ -287,7 +291,15 @@ def workspace_to_markdown(view: WorkspaceView) -> str: lines += ["", *_block_markdown(block)] if view.form is not None: action = view.actions[0] - lines += ["", form_to_markdown(view.form, action=action.id, submit_label=action.label)] + lines += [ + "", + form_to_markdown( + view.form, + action=action.id, + submit_label=action.label, + include_title=False, + ), + ] elif view.actions: lines += ["", "### Actions"] lines.extend( diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index ead4d9a..6c811b0 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -69,6 +69,7 @@ def test_semantic_state_matrix_is_present() -> None: "--ae-danger", "--ae-recommendation", "--ae-focus", + "--ae-space-md", ): assert role in css diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 5e0cbe9..d27aa80 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -81,6 +81,7 @@ def test_widget_display_actions_post_only_stable_action_id() -> None: assert 'data-workspace-action="run_fix" data-explicit="1"' in html assert 'data-workspace-action="edit_contract"' in html assert "action:b.getAttribute('data-workspace-action')" in html + assert "typeof sendPrompt==='function'" in html assert """" - return f'
{head}{sections}{content}{actions}{script}
' + return ( + f'
' + f"{head}{sections}{content}{actions}{script}
" + ) + + +def _markdown_text(value: str) -> str: + """Keep author text inside its current Markdown structural slot.""" + escaped = value.replace("\\", "\\\\") + for char in ("`", "*", "_", "[", "]", "<", ">", "|", "#"): + escaped = escaped.replace(char, f"\\{char}") + return escaped.replace("\n", "
") def _block_markdown(block: WorkspaceBlock) -> list[str]: if block.kind is WorkspaceBlockKind.KEY_VALUE: - return [f"- **{item.label}:** {item.value or item.detail}" for item in block.items] + return [ + f"- **{_markdown_text(item.label)}:** " f"{_markdown_text(item.value or item.detail)}" + for item in block.items + ] if block.kind is WorkspaceBlockKind.CODE: return [f"```{block.language}", block.body.replace("```", "` ` `"), "```"] if block.kind is WorkspaceBlockKind.EVIDENCE: def cell(value: str) -> str: - return value.replace("|", "\\|").replace("\n", "
") + return _markdown_text(value) rows = ["| Evidence | Result | Status |", "| --- | --- | --- |"] rows.extend( @@ -270,23 +309,24 @@ def cell(value: str) -> str: ) return rows if block.kind is WorkspaceBlockKind.DISCLOSURE: - return [f"**{block.title}**", "", block.body] + return [f"**{_markdown_text(block.title)}**", "", _markdown_text(block.body)] return [ - f"- {f'[{item.status}] ' if item.status else ''}**{item.label}**" - f"{f': {item.value}' if item.value else ''}" - f"{f' — {item.detail}' if item.detail else ''}" + f"- {f'[{_markdown_text(item.status)}] ' if item.status else ''}" + f"**{_markdown_text(item.label)}**" + f"{f': {_markdown_text(item.value)}' if item.value else ''}" + f"{f' — {_markdown_text(item.detail)}' if item.detail else ''}" for item in block.items ] def workspace_to_markdown(view: WorkspaceView) -> str: """Render a workspace view to the portable markdown surface.""" - lines = [f"## {view.title}"] + lines = [f"## {_markdown_text(view.title)}"] if view.summary: - lines += ["", view.summary] + lines += ["", _markdown_text(view.summary)] for section in view.sections: if section.heading: - lines += ["", f"### {section.heading}"] + lines += ["", f"### {_markdown_text(section.heading)}"] for block in section.blocks: lines += ["", *_block_markdown(block)] if view.form is not None: @@ -298,13 +338,14 @@ def workspace_to_markdown(view: WorkspaceView) -> str: action=action.id, submit_label=action.label, include_title=False, + view=view.id.value, ), ] elif view.actions: lines += ["", "### Actions"] lines.extend( - f"- `{action.id}` — {action.label}" - f"{f' — {action.consequence}' if action.consequence else ''}" + f"- `{action.id}` — {_markdown_text(action.label)}" + f"{f' — {_markdown_text(action.consequence)}' if action.consequence else ''}" for action in view.actions ) return "\n".join(lines) diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index 6c811b0..d195208 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -74,6 +74,13 @@ def test_semantic_state_matrix_is_present() -> None: assert role in css +def test_workspace_theme_consumes_dark_semantic_tokens() -> None: + css = theme.CSS_WORKSPACE + assert "prefers-color-scheme:dark" in css + assert "--ae-text:#f8f9ff" in css + assert "--ae-surface-raised:#1a2d42" in css + + #: A selector's STYLED class: the last ``.class`` token before the rule #: body (pseudo-classes stripped). ``.ae-card .ae-prog-tag`` styles #: ``ae-prog-tag``; ``.ae-card:hover`` styles ``ae-card``. diff --git a/tests/test_workspace.py b/tests/test_workspace.py index d27aa80..89065a9 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -29,6 +29,8 @@ def test_semantic_token_artifact_is_versioned_and_scalar_lookup_works() -> None: assert token("color.light.action") == "#004ac6" with pytest.raises(KeyError, match="mapping"): token("color.light") + with pytest.raises(TypeError): + SEMANTIC_TOKENS["color"]["light"]["action"] = "#f00" def test_workspace_rejects_executable_shape_and_ambiguous_authority() -> None: @@ -47,6 +49,12 @@ def test_workspace_rejects_executable_shape_and_ambiguous_authority() -> None: ) with pytest.raises(ValueError, match="code block requires body"): WorkspaceBlock(WorkspaceBlockKind.CODE) + with pytest.raises(ValueError, match="language identifier"): + WorkspaceBlock( + WorkspaceBlockKind.CODE, + body="safe", + language="text\n```\ninjected", + ) def test_form_view_requires_exactly_one_submit_action() -> None: @@ -73,6 +81,9 @@ def test_widget_form_action_uses_specific_label_and_stable_id() -> None: assert ">Preview fix" in html assert "payload.action = submitAction" in html assert 'var submitAction = "preview_fix"' in html + assert 'var submitView = "intake"' in html + assert html.count("All Constructs Reference" not in html assert "@import" not in html @@ -82,6 +93,10 @@ def test_widget_display_actions_post_only_stable_action_id() -> None: assert 'data-workspace-action="edit_contract"' in html assert "action:b.getAttribute('data-workspace-action')" in html assert "typeof sendPrompt==='function'" in html + assert "window.confirm(consequence)" in html + assert 'data-consequence="Execute the previewed contract."' in html + assert "Workspace action submitted" in html + assert "view:root.getAttribute('data-workspace-view')" in html assert "" not in html assert "<script>" in html + assert " None: + html = workspace_to_widget_html(showcase_views()[2], instance_id="foos²") + assert 'id="attune-workspace-foo"' in html From 52590b1db7f678515eb3599883a3bf752f7a31fb Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 29 Aug 2026 16:53:48 -0400 Subject: [PATCH 4/6] fix: apply workspace roundtable rulings --- src/attune_forms/theme.py | 50 ++++++++++++++++------- src/attune_forms/widget.py | 23 ++++++++++- src/attune_forms/workspace.py | 74 ++++++++++++++++++++++++++++------- tests/test_form_theme.py | 27 ++++++++++++- tests/test_workspace.py | 66 +++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 31 deletions(-) diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index fe8d0ff..ca3aa4c 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -44,6 +44,7 @@ "#attune-elicit-form {\n" f" --ae-action:var(--primary,{token('color.light.action')}); " f"--ae-action-hover:var(--primary-dark,{token('color.light.action_hover')});\n" + " --ae-action-text:var(--on-primary,#fff);\n" f" --ae-success:var(--text-success,{token('color.light.success')}); " f"--ae-warning:var(--text-accent,{token('color.light.warning')});\n" f" --ae-danger:var(--text-danger,{token('color.light.danger')}); " @@ -67,18 +68,19 @@ CSS_WORKSPACE_DARK_TOKENS = ( "@media (prefers-color-scheme:dark) { #attune-workspace {\n" - f" --ae-action:{token('color.dark.action')}; " - f"--ae-action-hover:{token('color.dark.action_hover')};\n" - f" --ae-success:{token('color.dark.success')}; " - f"--ae-warning:{token('color.dark.warning')}; " - f"--ae-danger:{token('color.dark.danger')};\n" - f" --ae-recommendation:{token('color.dark.recommendation')}; " - f"--ae-text:{token('color.dark.neutral_text')}; " - f"--ae-muted:{token('color.dark.neutral_muted')};\n" - f" --ae-surface:{token('color.dark.surface')}; " - f"--ae-surface-raised:{token('color.dark.surface_raised')};\n" - f" --ae-border:{token('color.dark.border')}; " - f"--ae-focus:{token('color.dark.focus')}; }} }}\n" + f" --ae-action:var(--primary,{token('color.dark.action')}); " + f"--ae-action-hover:var(--primary-dark,{token('color.dark.action_hover')});\n" + " --ae-action-text:var(--on-primary,#0b1c30);\n" + f" --ae-success:var(--text-success,{token('color.dark.success')}); " + f"--ae-warning:var(--text-accent,{token('color.dark.warning')}); " + f"--ae-danger:var(--text-danger,{token('color.dark.danger')});\n" + f" --ae-recommendation:var(--accent,{token('color.dark.recommendation')}); " + f"--ae-text:var(--text-primary,{token('color.dark.neutral_text')}); " + f"--ae-muted:var(--text-muted,{token('color.dark.neutral_muted')});\n" + f" --ae-surface:var(--surface-1,{token('color.dark.surface')}); " + f"--ae-surface-raised:var(--surface-2,{token('color.dark.surface_raised')});\n" + f" --ae-border:var(--border,{token('color.dark.border')}); " + f"--ae-focus:var(--focus-ring,{token('color.dark.focus')}); }} }}\n" ) #: Base rules every form emits (scoped under ``#attune-elicit-form``; @@ -116,6 +118,8 @@ border-radius:var(--ae-radius-control,8px); transition:background var(--ae-motion-fast,120ms); } #attune-elicit-form .ae-submit:hover { background:var(--ae-action-hover,#003ea8); } #attune-elicit-form .ae-submit:disabled { opacity:.6; cursor:default; } +#attune-elicit-form .ae-submit-consequence { color:var(--ae-muted,#5f6470); + font-size:13px; margin:.25rem 0; } #attune-elicit-form .ae-error { margin-top:.5rem; font-size:14px; color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-field-missing { border-left:3px solid @@ -298,7 +302,7 @@ #: The full theme: base + every family, in cascade order. This exact #: string is what the ops dashboard serves at ``/static/form-theme.css`` -#: (byte-equal by drift test) and what the 4 KB budget test measures. +#: (byte-equal by drift test) and what the 12 KB budget test measures. FORM_THEME_CSS = CSS_BASE + "".join(css for _name, css in CSS_FAMILIES) #: Workspace chrome is separate from ``FORM_THEME_CSS``: ordinary forms @@ -312,6 +316,10 @@ #attune-workspace .ae-ws-title { font-family:var(--ae-font-heading,system-ui); font-size:20px; font-weight:650; letter-spacing:-.015em; margin:0; } #attune-workspace .ae-ws-summary { color:var(--ae-muted,#5f6470); margin:.25rem 0 0; } +#attune-workspace [id^="attune-elicit-form-"] { --ae-action:inherit; + --ae-action-hover:inherit; --ae-action-text:inherit; --ae-danger:inherit; + --ae-text:inherit; --ae-muted:inherit; --ae-surface:inherit; + --ae-surface-raised:inherit; --ae-border:inherit; --ae-focus:inherit; } #attune-workspace .ae-ws-section { border-top:1px solid var(--ae-border,#c3c6d7); padding:1rem 0; } #attune-workspace .ae-ws-section:first-of-type { border-top:0; } @@ -320,13 +328,23 @@ padding:.55rem 1rem; border-radius:var(--ae-radius-control,8px); cursor:pointer; border:1px solid var(--ae-border,#c3c6d7); background:transparent; color:var(--ae-text,#0b1c30); font-weight:600; } -#attune-workspace .ae-ws-action-primary { color:#fff; +#attune-workspace .ae-ws-action-primary { color:var(--ae-action-text,#fff); background:var(--ae-action,#004ac6); border-color:var(--ae-action,#004ac6); } #attune-workspace .ae-ws-action-danger { color:var(--ae-danger,#ba1a1a); border-color:var(--ae-danger,#ba1a1a); } #attune-workspace .ae-ws-actions { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; } #attune-workspace .ae-ws-action-group { display:grid; gap:.25rem; } #attune-workspace .ae-ws-consequence { color:var(--ae-muted,#5f6470); font-size:12px; } +#attune-workspace .ae-ws-dispatch { min-height:1.25rem; color:var(--ae-success,#006c49); + font-size:13px; } +#attune-workspace [data-tone="recommendation"] { border-left:3px solid + var(--ae-recommendation,#7c3aed); padding-left:.75rem; } +#attune-workspace [data-tone="success"] { border-left:3px solid + var(--ae-success,#006c49); padding-left:.75rem; } +#attune-workspace [data-tone="warning"] { border-left:3px solid + var(--ae-warning,#a1571c); padding-left:.75rem; } +#attune-workspace [data-tone="danger"] { border-left:3px solid + var(--ae-danger,#ba1a1a); padding-left:.75rem; } #attune-workspace :is(button,summary):focus-visible { outline:3px solid var(--ae-focus,#2563eb); outline-offset:2px; } #attune-workspace .ae-ws-kv { display:grid; grid-template-columns:minmax(7rem,auto) 1fr; @@ -337,6 +355,10 @@ background:var(--ae-surface-raised,#eff4ff); border-radius:var(--ae-radius-control,8px); padding:.75rem; font-family:var(--ae-font-mono,ui-monospace); } #attune-workspace .ae-ws-list { display:grid; gap:.4rem; padding-left:1.25rem; } +#attune-workspace .ae-ws-change_summary { list-style:none; padding-left:0; } +#attune-workspace .ae-ws-change_summary li { border-left:3px solid + var(--ae-action,#004ac6); padding-left:.6rem; } +#attune-workspace .ae-ws-action_list { list-style:none; padding-left:0; } #attune-workspace .ae-ws-status { font-size:11px; font-weight:650; text-transform:uppercase; letter-spacing:.04em; color:var(--ae-muted,#5f6470); } #attune-workspace .ae-ws-evidence { width:100%; border-collapse:collapse; font-size:14px; } diff --git a/src/attune_forms/widget.py b/src/attune_forms/widget.py index a7d4cc4..1cc4040 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -22,6 +22,7 @@ from __future__ import annotations import json +import re import time import uuid from collections.abc import Callable @@ -51,6 +52,7 @@ #: form postback among ordinary chat messages and route it to #: ``collect_form_response``. Kept in sync with the ``elicit`` skill. WIDGET_RESPONSE_MARKER = "__elicitation_response__" +_CONTEXT_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") def _esc(value: object) -> str: @@ -699,7 +701,10 @@ def form_to_widget_html( submit_label: str | None = None, submit_action: str | None = None, submit_view: str | None = None, + submit_title: str | None = None, include_title: bool = True, + submit_consequence: str = "", + requires_explicit_choice: bool = False, ) -> str: """Render a declarative form as an inline ``show_widget`` HTML form. @@ -735,14 +740,22 @@ def form_to_widget_html( submit_action: Optional stable host action id included beside the validated answers in the postback. submit_view: Optional workspace view id included in the postback. + submit_title: Optional host workspace title used in the postback. include_title: Whether to emit the form headings. Workspace shells disable them because they own the heading hierarchy. + submit_consequence: User-visible effect of an explicit action. + requires_explicit_choice: Require confirmation before posting. Returns: An HTML string ready to pass straight to ``mcp__visualize__show_widget``. """ start = time.perf_counter() + for name, value in (("submit_action", submit_action), ("submit_view", submit_view)): + if value is not None and not _CONTEXT_ID_RE.fullmatch(value): + raise ValueError(f"{name} must be a stable lowercase identifier") + if requires_explicit_choice and not submit_consequence.strip(): + raise ValueError("an explicit submit action requires a consequence") sfx = "".join(c for c in (instance_id or "") if c.isalnum()) or uuid.uuid4().hex[:8] form_id = f"attune-elicit-form-{sfx}" intro = f'

{_esc(message)}

' if message else "" @@ -765,19 +778,26 @@ def form_to_widget_html( button_label = submit_label or ("Confirm" if confirm else "Submit") action_js = json.dumps(submit_action) view_js = json.dumps(submit_view) + explicit_js = "true" if requires_explicit_choice else "false" title_html = ( f'

{_esc(form.title)} — interactive form

\n' f"

{_esc(form.title)}

" if include_title else "" ) + consequence_html = ( + f'

{_esc(submit_consequence)}

' + if submit_consequence + else "" + ) - html = f""" + html = f""" {title_html} {intro}{desc}{confirm_html} {fields} +{consequence_html} """ @@ -331,6 +355,11 @@ def workspace_to_markdown(view: WorkspaceView) -> str: lines += ["", *_block_markdown(block)] if view.form is not None: action = view.actions[0] + if action.requires_explicit_choice: + lines += [ + "", + f"**Explicit confirmation required:** {_markdown_text(action.consequence)}", + ] lines += [ "", form_to_markdown( @@ -348,4 +377,21 @@ def workspace_to_markdown(view: WorkspaceView) -> str: f"{f' — {_markdown_text(action.consequence)}' if action.consequence else ''}" for action in view.actions ) + lines += [ + "", + "Reply with the selected `action` value in this payload:", + "", + "```json", + json.dumps( + { + WIDGET_RESPONSE_MARKER: True, + "title": view.title, + "view": view.id.value, + "action": None, + }, + indent=2, + ensure_ascii=False, + ), + "```", + ] return "\n".join(lines) diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index d195208..4b5798b 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -7,6 +7,7 @@ from attune_forms import theme from attune_forms import widget as widget_mod +from attune_forms.tokens import token #: Chair-ruled cap (workflow-intake-forms decisions.md D1: 4 KB -> 6 KB #: with the 5,574 B measurement; 6 KB -> 8 KB with the grammar-expansion @@ -14,6 +15,7 @@ #: decisions.md D2-a — a consolidation pass was offered and NOT chosen, #: so the cap is not a ratchet: the next raise needs its own ruling). _BUDGET_BYTES = 12288 +_WORKSPACE_BUDGET_BYTES = 6144 #: ``var(--name)`` with NO fallback value — the pattern the theme #: must never contain (host-token fallbacks are the design). @@ -29,6 +31,10 @@ def test_form_theme_budget() -> None: ) +def test_workspace_theme_budget() -> None: + assert len(theme.CSS_WORKSPACE.encode("utf-8")) <= _WORKSPACE_BUDGET_BYTES + + def test_forbidden_latency_constructs_absent() -> None: low = theme.FORM_THEME_CSS.lower() for banned in ("@import", "@font-face", "url("): @@ -77,8 +83,25 @@ def test_semantic_state_matrix_is_present() -> None: def test_workspace_theme_consumes_dark_semantic_tokens() -> None: css = theme.CSS_WORKSPACE assert "prefers-color-scheme:dark" in css - assert "--ae-text:#f8f9ff" in css - assert "--ae-surface-raised:#1a2d42" in css + assert "--ae-surface-raised:var(--surface-2,#1a2d42)" in css + assert "--ae-action-text:var(--on-primary,#0b1c30)" in css + assert "--ae-text:var(--text-primary,#f8f9ff)" in css + + +def _relative_luminance(hex_color: str) -> float: + channels = [int(hex_color[index : index + 2], 16) / 255 for index in (1, 3, 5)] + linear = [ + value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + for value in channels + ] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + +def test_dark_primary_action_meets_wcag_aa_contrast() -> None: + foreground = _relative_luminance(token("color.light.neutral_text")) + background = _relative_luminance(token("color.dark.action")) + ratio = (max(foreground, background) + 0.05) / (min(foreground, background) + 0.05) + assert ratio >= 4.5 #: A selector's STYLED class: the last ``.class`` token before the rule diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 89065a9..6978817 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -3,6 +3,9 @@ from __future__ import annotations import json +import re +import shutil +import subprocess import pytest @@ -16,6 +19,7 @@ WorkspaceView, WorkspaceViewId, form_from_dict, + form_to_widget_html, workspace_to_markdown, workspace_to_widget_html, ) @@ -55,6 +59,14 @@ def test_workspace_rejects_executable_shape_and_ambiguous_authority() -> None: body="safe", language="text\n```\ninjected", ) + with pytest.raises(TypeError, match="block kind"): + WorkspaceBlock("code", body="x") # type: ignore[arg-type] + with pytest.raises(TypeError, match="section tone"): + WorkspaceSection(blocks=(WorkspaceBlock(WorkspaceBlockKind.CODE, body="x"),), tone="x") # type: ignore[arg-type] + with pytest.raises(TypeError, match="action intent"): + WorkspaceAction("run", "Run", intent="primary") # type: ignore[arg-type] + with pytest.raises(TypeError, match="view id"): + WorkspaceView(id="preview", title="T") # type: ignore[arg-type] def test_form_view_requires_exactly_one_submit_action() -> None: @@ -82,11 +94,19 @@ def test_widget_form_action_uses_specific_label_and_stable_id() -> None: assert "payload.action = submitAction" in html assert 'var submitAction = "preview_fix"' in html assert 'var submitView = "intake"' in html + assert 'data-form-title="Fix"' in html assert html.count("All Constructs Reference" not in html assert "@import" not in html +def test_public_widget_context_rejects_script_values() -> None: + form = showcase_views()[0].form + assert form is not None + with pytest.raises(ValueError, match="stable lowercase identifier"): + form_to_widget_html(form, submit_action="") + + def test_widget_display_actions_post_only_stable_action_id() -> None: html = workspace_to_widget_html(showcase_views()[1], instance_id="preview") assert 'data-workspace-action="run_fix" data-explicit="1"' in html @@ -97,6 +117,45 @@ def test_widget_display_actions_post_only_stable_action_id() -> None: assert 'data-consequence="Execute the previewed contract."' in html assert "Workspace action submitted" in html assert "view:root.getAttribute('data-workspace-view')" in html + assert 'role="status" aria-live="polite"' in html + assert "x.disabled=true" in html + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") +def test_widget_display_action_script_parses() -> None: + html = workspace_to_widget_html(showcase_views()[1], instance_id="parse") + script = re.search(r"", html, re.DOTALL) + assert script is not None + result = subprocess.run( + ["node", "--check"], + input=script.group(1), + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_form_workspace_preserves_explicit_action_semantics() -> None: + intake = showcase_views()[0] + action = WorkspaceAction( + "preview_fix", + "Preview fix", + WorkspaceActionIntent.PRIMARY, + consequence="Build the deterministic preview.", + requires_explicit_choice=True, + ) + view = WorkspaceView( + id=intake.id, + title=intake.title, + form=intake.form, + actions=(action,), + ) + html = workspace_to_widget_html(view, instance_id="explicit") + assert "Build the deterministic preview." in html + assert "window.confirm(form.getAttribute('data-submit-consequence'))" in html + markdown = workspace_to_markdown(view) + assert "**Explicit confirmation required:** Build the deterministic preview." in markdown assert "