diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 32daaa1..2c8cd89 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Structured agent-user communication: validated forms, decision cards with recommendations, structured pushback, and progress reports \u2014 batch questions instead of asking one at a time.", - "version": "0.8.0" + "version": "0.9.0" }, "plugins": [ { "name": "attune-forms", "description": "The communication grammar for AI agents: batch independent questions into ONE validated form; offer recommendations as decision cards with rationales and per-option tradeoffs; disagree constructively via pushback cards; report multi-step progress with a blocked-item picker. Renders rich HTML where the host supports widgets and degrades cleanly to plain questions everywhere else. Powered by the attune-forms PyPI package via a bundled MCP server.", "source": "./plugin", - "version": "0.8.0", + "version": "0.9.0", "author": { "name": "Smart AI Memory", "email": "admin@smartaimemory.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c9e8c..5f400ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,41 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +## [0.9.0] — 2026-08-29 + +### Added +- **Fix-first command workspace grammar** — four portable state views + (`intake`, `preview`, `execution`, `receipt`) compose the existing + validated `FormSchema` with a closed display-block vocabulary and stable, + host-dispatched actions. Widget and Markdown renderers preserve the same + view/action return contract without accepting executable callbacks or + arbitrary HTML. +- **Provider-neutral semantic token artifact** — versioned light/dark color + roles, typography, spacing, radius, motion, and control targets are loaded + from packaged JSON and exposed as a recursively immutable mapping. Shared + form CSS and separately-budgeted workspace CSS project from that source. +- **Workspace showcase and hostile-boundary receipts** — all four views, + every form construct, and every display block are exercised. Tests parse + emitted action JavaScript with Node, reject script-context action values, + calculate WCAG AA dark-action contrast, pin explicit confirmation parity, + and enforce independent form/workspace CSS budgets. + +### Changed +- Form widget and portable Markdown renderers accept optional stable action + and view context, action-specific labels, workspace-owned titles, and + explicit-action consequences while retaining their existing defaults for + standalone callers. + ### Fixed +- Display-action widgets now emit valid JavaScript, disable actions after + dispatch, announce success through a live region, and send the same fenced + sentinel grammar as form-backed views. +- Dark workspace tokens retain host-variable fallbacks, embedded forms inherit + the workspace profile, and primary-action foregrounds meet WCAG AA. +- Runtime enum guards, Markdown structural escaping, code-language validation, + evidence-table scopes, stable instance ids, and recursive token freezing + close the contract and accessibility gaps found by cross-review and the + three-seat release-readiness roundtable. - **Multi-line item `detail` kept its shape on both rendering surfaces** (round table `q-forms-hunk-review-001`, 2026-08-28). A `detail` carrying more than one line — a diff hunk, a log excerpt — was diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 68072d9..dccbdf8 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "attune-forms", - "version": "0.8.0", + "version": "0.9.0", "description": "Structured agent-user communication \u2014 validated forms, decision cards, pushback, progress reports, deliberation, triage boards, confirm gates, rankings, and assumption reviews via the attune-forms MCP server.", "author": { "name": "Smart AI Memory", diff --git a/pyproject.toml b/pyproject.toml index 8918489..00164ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "attune-forms" -version = "0.8.0" +version = "0.9.0" description = "Dynamic forms library: declarative FormSchema, multi-surface renderers (widget HTML, AskUserQuestion, MCP elicitation), and template-driven intake generation" readme = "README.md" requires-python = ">=3.10" @@ -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..186cef8 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -322,7 +322,12 @@ 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, + view: 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 +338,26 @@ 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}, } - - -def form_to_markdown(form: FormSchema, message: str = "") -> str: + if action is not None: + payload["action"] = action + if view is not None: + payload["view"] = view + return payload + + +def form_to_markdown( + form: FormSchema, + message: str = "", + action: str | None = None, + submit_label: str | None = None, + include_title: bool = True, + view: 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,11 +371,18 @@ 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. + include_title: Whether to emit the form's level-two heading. + Workspace renderers disable it because their shell already + owns the view heading. + view: Optional workspace view id added to the answer skeleton. 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: @@ -373,13 +397,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, view=view)), ] 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..ca3aa4c 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -38,13 +38,61 @@ 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" + " --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')}); " + 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-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" + f" --ae-font-body:{token('typography.body')}; " + f"--ae-font-heading:{token('typography.heading')}; " + f"--ae-font-mono:{token('typography.mono')}; }}\n" +) + +CSS_WORKSPACE_DARK_TOKENS = ( + "@media (prefers-color-scheme:dark) { #attune-workspace {\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``; #: 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 +113,33 @@ #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-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 - 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 +167,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); } @@ -241,5 +302,74 @@ #: 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 +#: 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 [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; } +#attune-workspace .ae-ws-section h3 { 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: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; + 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-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; } +#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; } } +""" + + CSS_WORKSPACE_DARK_TOKENS +) diff --git a/src/attune_forms/tokens.py b/src/attune_forms/tokens.py new file mode 100644 index 0000000..ce705c1 --- /dev/null +++ b/src/attune_forms/tokens.py @@ -0,0 +1,48 @@ +"""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 collections.abc import Mapping +from importlib.resources import files +from types import MappingProxyType +from typing import Any + + +def _freeze(value: Any) -> Any: + if isinstance(value, dict): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + return value + + +def _load_tokens() -> Mapping[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 _freeze(data) + + +SEMANTIC_TOKENS = _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, Mapping): + 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..1cc4040 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -21,6 +21,8 @@ from __future__ import annotations +import json +import re import time import uuid from collections.abc import Callable @@ -50,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: @@ -695,6 +698,13 @@ def form_to_widget_html( form: FormSchema, message: str = "", instance_id: str | None = None, + 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. @@ -725,12 +735,27 @@ 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. + 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 "" @@ -750,16 +775,30 @@ 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) + 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"""

{_esc(form.title)} — interactive form

-
+ html = f""" -

{_esc(form.title)}

+{title_html} {intro}{desc}{confirm_html} {fields} - +{consequence_html} + """ + 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"- **{_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 _markdown_text(value) + + rows = ["| Evidence | Result | Status |", "| --- | --- | --- |"] + rows.extend( + f"| {cell(item.label)} | {cell(item.value)} | " f"{cell(item.status or item.detail)} |" + for item in block.items + ) + return rows + if block.kind is WorkspaceBlockKind.DISCLOSURE: + return [f"**{_markdown_text(block.title)}**", "", _markdown_text(block.body)] + return [ + 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"## {_markdown_text(view.title)}"] + if view.summary: + lines += ["", _markdown_text(view.summary)] + for section in view.sections: + if section.heading: + lines += ["", f"### {_markdown_text(section.heading)}"] + for block in section.blocks: + 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( + view.form, + action=action.id, + submit_label=action.label, + include_title=False, + view=view.id.value, + ), + ] + elif view.actions: + lines += ["", "### Actions"] + lines.extend( + f"- `{action.id}` — {_markdown_text(action.label)}" + 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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c917853 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""attune-forms test package and shared fixtures.""" 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..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("): @@ -51,6 +57,53 @@ 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", + "--ae-space-md", + ): + 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-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 #: 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..6978817 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,246 @@ +"""Fix-first command workspace model and renderer receipts.""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess + +import pytest + +from attune_forms import ( + WorkspaceAction, + WorkspaceActionIntent, + WorkspaceBlock, + WorkspaceBlockKind, + WorkspaceItem, + WorkspaceSection, + WorkspaceView, + WorkspaceViewId, + form_from_dict, + form_to_widget_html, + 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") + with pytest.raises(TypeError): + SEMANTIC_TOKENS["color"]["light"]["action"] = "#f00" + + +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) + with pytest.raises(ValueError, match="language identifier"): + WorkspaceBlock( + WorkspaceBlockKind.CODE, + 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: + 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 '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 + 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 '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 "" 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 + hyphenated = workspace_to_widget_html(showcase_views()[2], instance_id="fix-1") + compact = workspace_to_widget_html(showcase_views()[2], instance_id="fix1") + assert 'id="attune-workspace-fix-1"' in hyphenated + assert 'id="attune-workspace-fix1"' in compact