@guanlin4924/devflow-pi 0.1.40 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/apply-opencode-go-session-hotfix.mjs +61 -0
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  4. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  5. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  9. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  45. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  47. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  48. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  49. package/src/constants.ts +1 -1
  50. package/src/runtime.ts +53 -22
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Core-owned persistence for wireframe canonical metadata and approval.
5
+ require "digest"
6
+ require "json"
7
+ require "open3"
8
+ require "optparse"
9
+ require "time"
10
+ require "yaml"
11
+ require_relative "control_io"
12
+
13
+ BUSINESS_VALIDATOR = File.expand_path("business_model_validate.py", __dir__)
14
+
15
+ options = {
16
+ action: "meta",
17
+ feature: nil,
18
+ canonical: nil,
19
+ state: ".planning/STATE.md",
20
+ expected_revision: nil,
21
+ confirmed_screens: nil,
22
+ approved_by: nil,
23
+ runtime_confirmed: false,
24
+ all_screens: false,
25
+ write: false,
26
+ json: false,
27
+ business_canonical: nil
28
+ }
29
+
30
+ OptionParser.new do |opts|
31
+ opts.banner = "Usage: wireframe_commit.rb --action meta|approve --feature NAME --write"
32
+ opts.on("--action ACTION", %w[meta approve]) { |value| options[:action] = value }
33
+ opts.on("--feature NAME") { |value| options[:feature] = value }
34
+ opts.on("--canonical PATH") { |value| options[:canonical] = value }
35
+ opts.on("--state PATH") { |value| options[:state] = value }
36
+ opts.on("--expected-revision REVISION", Integer) { |value| options[:expected_revision] = value }
37
+ opts.on("--confirmed-screens IDS", "Comma-separated screen IDs") { |value| options[:confirmed_screens] = value }
38
+ opts.on("--approved-by ACTOR") { |value| options[:approved_by] = value }
39
+ opts.on("--runtime-confirmed", "Internal runtime authorization for user confirmation") { options[:runtime_confirmed] = true }
40
+ opts.on("--all-screens", "Internal runtime confirmation of the current canonical screen set") { options[:all_screens] = true }
41
+ opts.on("--write") { options[:write] = true }
42
+ opts.on("--json") { options[:json] = true }
43
+ opts.on("--business-canonical PATH") { |value| options[:business_canonical] = value }
44
+ end.parse!
45
+
46
+ ROOT = Dir.pwd
47
+
48
+ def front_matter(markdown)
49
+ match = markdown.match(/\A---\n(.*?)\n---\n/m)
50
+ match && match[1]
51
+ end
52
+
53
+ def load_state(path)
54
+ yaml = front_matter(File.read(path))
55
+ raise ArgumentError, "missing state front matter" if yaml.nil?
56
+
57
+ YAML.safe_load(yaml, permitted_classes: [], aliases: false)
58
+ end
59
+
60
+ ROOT_ORDER = %w[schema_version feature screens].freeze
61
+ SCREEN_ORDER = %w[id title presentation platforms requirement_refs elements actions states].freeze
62
+ ELEMENT_ORDER = %w[id type title label action_refs action_ref fields columns items tabs].freeze
63
+ ACTION_ORDER = %w[id type target].freeze
64
+ INLINE_ORDER = %w[id label type title meta value action_ref].freeze
65
+
66
+ def canonical_reorder(value, order = INLINE_ORDER)
67
+ return value.map { |item| canonical_reorder(item, INLINE_ORDER) } if value.is_a?(Array)
68
+ return value unless value.is_a?(Hash)
69
+
70
+ keys = order.select { |key| value.key?(key) }
71
+ keys += value.keys.map(&:to_s).reject { |key| keys.include?(key) }.sort
72
+ keys.each_with_object({}) { |key, result| result[key] = canonical_reorder(value[key], INLINE_ORDER) }
73
+ end
74
+
75
+ def canonical_normalize(value)
76
+ return value.unicode_normalize(:nfkc).rstrip if value.is_a?(String)
77
+ return value.map { |item| canonical_normalize(item) } if value.is_a?(Array)
78
+ return value unless value.is_a?(Hash)
79
+
80
+ value.each_with_object({}) do |(key, item), result|
81
+ result[key] = canonical_normalize(item)
82
+ end
83
+ end
84
+
85
+ def canonical_order(canonical)
86
+ root = ROOT_ORDER.select { |key| canonical.key?(key) }
87
+ root += canonical.keys.map(&:to_s).reject { |key| root.include?(key) }.sort
88
+ root.each_with_object({}) do |key, result|
89
+ order = key == "screens" ? SCREEN_ORDER : ROOT_ORDER
90
+ result[key] = if key == "screens"
91
+ Array(canonical[key]).map do |screen|
92
+ canonical_reorder(screen, SCREEN_ORDER).tap do |ordered|
93
+ ordered["elements"] = Array(screen["elements"]).map { |element| canonical_reorder(element, ELEMENT_ORDER) }
94
+ ordered["actions"] = Array(screen["actions"]).map { |action| canonical_reorder(action, ACTION_ORDER) }
95
+ end
96
+ end
97
+ else
98
+ canonical_reorder(canonical[key], order)
99
+ end
100
+ end
101
+ end
102
+
103
+ def canonical_fingerprint(canonical)
104
+ normalized = canonical_normalize(canonical)
105
+ encoded = JSON.generate(canonical_order(normalized)) + "\n"
106
+ "sha256:#{Digest::SHA256.hexdigest(encoded)}"
107
+ end
108
+
109
+ def read_source_fingerprint(root, feature)
110
+ path = File.join(root, ".planning", "requirements", feature, "traceability.json")
111
+ return "" unless File.file?(path)
112
+
113
+ JSON.parse(File.read(path))["source_document_fingerprint"].to_s
114
+ end
115
+
116
+ def verify_business_action_refs(canonical, path)
117
+ business = JSON.parse(File.read(path))
118
+ action_ids = Array(business["actions"]).select { |item| item.is_a?(Hash) }.map { |item| item["id"].to_s }
119
+ Array(canonical["screens"]).each do |screen|
120
+ Array(screen["actions"]).each do |action|
121
+ next unless action.is_a?(Hash) && action["type"].to_s == "open"
122
+ ref = action["business_action_ref"].to_s
123
+ raise ArgumentError, "business_action_ref is required for open action #{action['id']}" if ref.empty?
124
+ raise ArgumentError, "business action reference is unresolved: #{ref}" unless action_ids.include?(ref)
125
+ end
126
+ end
127
+ rescue JSON::ParserError => e
128
+ raise ArgumentError, "invalid business canonical JSON: #{e.message}"
129
+ end
130
+
131
+ def business_fingerprint(path)
132
+ stdout, stderr, status = Open3.capture3("python3", BUSINESS_VALIDATOR, "--proposal", path.to_s)
133
+ raise ArgumentError, "business canonical validation failed: #{stderr.strip}" unless status.success?
134
+
135
+ JSON.parse(stdout).fetch("canonical_fingerprint")
136
+ rescue JSON::ParserError, KeyError => error
137
+ raise ArgumentError, "invalid business canonical validator output: #{error.message}"
138
+ end
139
+
140
+ def result_payload(value, options)
141
+ puts(options[:json] ? JSON.pretty_generate(value) : (value["message"] || (value["ok"] ? "ok" : "failed")))
142
+ end
143
+
144
+ project_lock = nil
145
+ begin
146
+ raise ArgumentError, "feature is required" if options[:feature].to_s.empty?
147
+ raise ArgumentError, "--write is required" unless options[:write]
148
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
149
+ if options[:action] == "approve"
150
+ raise ArgumentError, "--approved-by is required" if options[:approved_by].to_s.empty?
151
+ raise ArgumentError, "--runtime-confirmed is required for approval" unless options[:runtime_confirmed]
152
+ end
153
+
154
+ canonical_relative = options[:canonical] || ".planning/features/#{options[:feature]}/canonical.json"
155
+ state_absolute, = DevflowControlIO.resolve_control_path(ROOT, options[:state], :state)
156
+ canonical_absolute, canonical_path = DevflowControlIO.resolve_control_path(ROOT, canonical_relative, :artifact)
157
+ raise ArgumentError, "canonical.json does not exist" unless File.file?(canonical_absolute)
158
+
159
+ project_lock = DevflowControlIO.acquire_project_lock(ROOT)
160
+ state = load_state(state_absolute)
161
+ raise ArgumentError, "state feature mismatch" unless state["current_feature"].to_s == options[:feature].to_s
162
+ unless state["revision"].to_i == options[:expected_revision]
163
+ raise ArgumentError, "state revision mismatch: expected #{options[:expected_revision]}, got #{state["revision"]}"
164
+ end
165
+
166
+ canonical = JSON.parse(File.read(canonical_absolute))
167
+ raise ArgumentError, "canonical feature mismatch" unless canonical["feature"].to_s == options[:feature].to_s
168
+ business_fingerprint_value = nil
169
+ if options[:business_canonical]
170
+ business_absolute, = DevflowControlIO.resolve_control_path(ROOT, options[:business_canonical], :artifact)
171
+ verify_business_action_refs(canonical, business_absolute)
172
+ business_fingerprint_value = business_fingerprint(business_absolute)
173
+ end
174
+ prototype_fingerprint = canonical_fingerprint(canonical)
175
+ source_fingerprint = read_source_fingerprint(ROOT, options[:feature])
176
+ raise ArgumentError, "source_document_fingerprint is required" if source_fingerprint.empty?
177
+
178
+ screen_ids = Array(canonical["screens"]).map { |screen| screen["id"].to_s }.sort
179
+ meta_path = File.join(ROOT, ".planning", "features", options[:feature], "canonical.meta.json")
180
+ meta = {
181
+ "schema_version" => 1,
182
+ "feature" => options[:feature],
183
+ "canonical_path" => canonical_path,
184
+ "prototype_fingerprint" => prototype_fingerprint,
185
+ "source_document_fingerprint" => source_fingerprint,
186
+ "business_prototype_fingerprint" => business_fingerprint_value,
187
+ "state_revision" => state["revision"],
188
+ "built_by" => "devflow-core/wireframe-commit",
189
+ "built_at" => Time.now.utc.iso8601
190
+ }
191
+ DevflowControlIO.atomic_write(meta_path, JSON.pretty_generate(meta) + "\n")
192
+ output = { "ok" => true, "action" => "meta", "meta" => meta }
193
+
194
+ if options[:action] == "approve"
195
+ confirmed = options[:all_screens] ? screen_ids : options[:confirmed_screens].to_s.split(",").map(&:strip).reject(&:empty?).sort
196
+ raise ArgumentError, "--confirmed-screens is required" if confirmed.empty?
197
+ raise ArgumentError, "confirmed screen set does not match canonical" unless confirmed == screen_ids
198
+
199
+ approval = {
200
+ "schema_version" => 1,
201
+ "feature" => options[:feature],
202
+ "status" => "approved",
203
+ "prototype_fingerprint" => prototype_fingerprint,
204
+ "source_document_fingerprint" => source_fingerprint,
205
+ "business_prototype_fingerprint" => business_fingerprint_value,
206
+ "state_revision" => state["revision"],
207
+ "confirmed_screens" => confirmed,
208
+ "approved_by" => options[:approved_by],
209
+ "approved_at" => Time.now.utc.iso8601
210
+ }
211
+ approval_path = File.join(ROOT, ".planning", "features", options[:feature], "approval.json")
212
+ DevflowControlIO.atomic_write(approval_path, JSON.pretty_generate(approval) + "\n")
213
+ output["action"] = "approve"
214
+ output["approval"] = approval
215
+ end
216
+ result_payload(output, options)
217
+ rescue StandardError => error
218
+ result_payload({ "ok" => false, "reason" => "wireframe_commit_failed", "message" => error.message }, options)
219
+ exit 1
220
+ ensure
221
+ DevflowControlIO.release_lock(project_lock)
222
+ end
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic low-fidelity Markdown/SVG renderer and static gallery builder."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import hashlib
8
+ import html
9
+ import json
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import sys
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from wireframe_validate import canonical_order, normalize # type: ignore
17
+
18
+
19
+ def esc(value: Any) -> str:
20
+ return html.escape(str(value), quote=True)
21
+
22
+
23
+ def text(value: Any, default: str = "") -> str:
24
+ return esc(value if value is not None else default)
25
+
26
+
27
+ def svg_text(x: int, y: int, value: Any, size: int = 13, fill: str = "#0f172a", weight: str = "400") -> str:
28
+ return f'<text x="{x}" y="{y}" font-size="{size}" fill="{fill}" font-weight="{weight}">{text(value)}</text>'
29
+
30
+
31
+ def gfm_cell(value: Any) -> str:
32
+ return str(value if value is not None else "").replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>")
33
+
34
+
35
+ def mermaid_label(value: Any) -> str:
36
+ return (str(value if value is not None else "")
37
+ .replace("&", "#38;")
38
+ .replace('"', "#quot;")
39
+ .replace("[", "#91;")
40
+ .replace("]", "#93;")
41
+ .replace("\r", " ").replace("\n", " "))
42
+
43
+
44
+ def render_screen(screen: dict[str, Any], platform: str) -> str:
45
+ mobile = platform == "mobile"
46
+ width, height = (390, 760) if mobile else (960, 600)
47
+ pad = 20 if mobile else 48
48
+ pieces = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{text(screen.get("title"))} {platform} 页面线框">', f'<rect width="{width}" height="{height}" fill="#f8fafc"/>']
49
+ y = 45 if mobile else 58
50
+ if screen.get("presentation") in {"modal", "drawer"}:
51
+ if mobile:
52
+ panel_x, panel_y, panel_w, panel_h = 0, 220, width, height - 220
53
+ pad, y = 24, 265
54
+ elif screen.get("presentation") == "drawer":
55
+ panel_x, panel_y, panel_w, panel_h = 540, 0, 420, height
56
+ pad, y = 570, 48
57
+ else:
58
+ panel_x, panel_y, panel_w, panel_h = 270, 70, 420, 460
59
+ pad, y = 300, 115
60
+ pieces.append(f'<rect width="{width}" height="{height}" fill="#0f172a33"/>')
61
+ pieces.append(f'<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" rx="12" fill="white" stroke="#7c3aed" stroke-width="2"/>')
62
+ content_right = panel_x + panel_w - (24 if mobile else 30)
63
+ else:
64
+ content_right = width - pad
65
+ content_width = content_right - pad
66
+ for element in screen.get("elements", []):
67
+ typ = element.get("type")
68
+ if typ == "page_header":
69
+ pieces.append(svg_text(pad, y, element.get("title", screen.get("title")), 21 if mobile else 25, weight="700"))
70
+ y += 48
71
+ elif typ == "filter_bar":
72
+ box_h = 92 if mobile else 76
73
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
74
+ fields = element.get("fields", [])
75
+ for i, field in enumerate(fields[:2]):
76
+ x = pad + 16 if mobile else pad + 24 + i * 220
77
+ pieces.append(svg_text(x, y + 28, field.get("label"), 12, "#64748b"))
78
+ pieces.append(f'<rect x="{x}" y="{y + 38}" width="{150 if mobile else 180}" height="24" fill="#f1f5f9" stroke="#cbd5e1"/>')
79
+ y += box_h + 22
80
+ elif typ == "table":
81
+ box_h = 290 if mobile else 300
82
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
83
+ columns = element.get("columns", [])
84
+ if mobile:
85
+ pieces.append(svg_text(pad + 16, y + 36, "列表", 14, weight="700"))
86
+ for row, label in enumerate(["示例任务一", "示例任务二"]):
87
+ ry = y + 78 + row * 72
88
+ pieces.extend([svg_text(pad + 16, ry, label), svg_text(pad + 16, ry + 24, "运行中 · 2026-09-01", 12, "#64748b"), f'<path d="M{pad + 16} {ry + 38} H{width - pad - 16}" stroke="#e2e8f0"/>'])
89
+ else:
90
+ for i, col in enumerate(columns):
91
+ pieces.append(svg_text(pad + 24 + i * 160, y + 36, col.get("label"), 13, "#475569"))
92
+ for row in range(4):
93
+ ry = y + 74 + row * 54
94
+ pieces.append(f'<path d="M{pad + 16} {ry} H{content_right - 16}" stroke="#e2e8f0"/>')
95
+ if row < 2:
96
+ pieces.extend([svg_text(pad + 24, ry + 32, "示例任务"), svg_text(pad + 184, ry + 32, "运行中"), svg_text(pad + 344, ry + 32, "2026-09-01"), svg_text(pad + 504, ry + 32, "查看", 13, "#2563eb")])
97
+ y += box_h + 22
98
+ elif typ == "card_list":
99
+ for item in element.get("items", []):
100
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{width - 2 * pad}" height="90" rx="8" fill="white" stroke="#cbd5e1"/>')
101
+ pieces.append(svg_text(pad + 20, y + 34, item.get("title"), 14, weight="700"))
102
+ meta = item.get("meta", [])
103
+ pieces.append(svg_text(pad + 20, y + 62, " · ".join(f'{m.get("label")}: {m.get("value")}' for m in meta), 12, "#64748b"))
104
+ y += 108
105
+ elif typ == "tabs":
106
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="48" fill="white" stroke="#cbd5e1"/>')
107
+ for i, tab in enumerate(element.get("tabs", [])):
108
+ pieces.append(svg_text(pad + 20 + i * 120, y + 30, tab.get("label"), 14, "#2563eb" if i == 0 else "#64748b"))
109
+ y += 70
110
+ elif typ == "bottom_navigation":
111
+ bar_h = 58
112
+ bar_y = height - bar_h - 18
113
+ pieces.append(f'<rect x="{pad}" y="{bar_y}" width="{content_width}" height="{bar_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
114
+ items = element.get("items", [])
115
+ slot = max(1, (width - 2 * pad) // max(1, len(items)))
116
+ for i, item in enumerate(items):
117
+ pieces.append(f'<text x="{pad + slot * i + slot // 2}" y="{bar_y + 35}" text-anchor="middle" font-size="12" fill="#475569">{text(item.get("label"))}</text>')
118
+ elif typ in {"input", "select"}:
119
+ pieces.append(svg_text(pad, y, element.get("label"), 12, "#64748b"))
120
+ pieces.append(f'<rect x="{pad}" y="{y + 10}" width="{content_width}" height="38" fill="white" stroke="#cbd5e1"/>')
121
+ y += 70
122
+ elif typ in {"button", "icon_button"}:
123
+ bw = 150 if mobile else 140
124
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{bw}" height="40" rx="5" fill="#334155"/>')
125
+ pieces.append(f'<text x="{pad + bw // 2}" y="{y + 26}" text-anchor="middle" font-size="13" fill="white">{text(element.get("label"))}</text>')
126
+ y += 58
127
+ pieces.append("</svg>")
128
+ return "".join(pieces)
129
+
130
+
131
+ def render_flow(screens: list[dict[str, Any]]) -> str:
132
+ width = max(760, 220 * len(screens) + 80)
133
+ parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="180" viewBox="0 0 {width} 180" role="img" aria-label="页面流转图"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#64748b"/></marker></defs><rect width="100%" height="100%" fill="#f8fafc"/>']
134
+ ids = {s.get("id"): i for i, s in enumerate(screens)}
135
+ for i, screen in enumerate(screens):
136
+ x = 40 + i * 220
137
+ modal = screen.get("presentation") in {"modal", "drawer"}
138
+ shape = f'<path d="M{x + 90} 62 L{x + 180} 90 L{x + 90} 118 L{x} 90 Z"' if modal else f'<rect x="{x}" y="62" width="180" height="56" rx="8"'
139
+ parts.append(shape + ' fill="#fff" stroke="#334155" stroke-width="2"/>')
140
+ parts.append(f'<text x="{x + 90}" y="96" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" fill="#0f172a">{mermaid_label(screen.get("title"))}</text>')
141
+ for action in screen.get("actions", []):
142
+ if action.get("type") == "open" and action.get("target") in ids:
143
+ tx = 40 + ids[action["target"]] * 220
144
+ parts.append(f'<path d="M{x + 180} 90 H{tx}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>')
145
+ parts.append("</svg>")
146
+ return "".join(parts)
147
+
148
+
149
+ def render_preview(screens: list[dict[str, Any]], feature: str) -> str:
150
+ lines = [f"# 低保真原型:{feature}", "", "> 此文件由 canonical.json 确定性生成,仅用于结构审查。", "", "## 页面流转", "", "```mermaid", "flowchart LR"]
151
+ for screen in screens:
152
+ sid = screen["id"].replace("-", "_")
153
+ lines.append(f' {sid}["{mermaid_label(screen["title"])}"]')
154
+ for screen in screens:
155
+ for action in screen.get("actions", []):
156
+ if action.get("type") == "open":
157
+ lines.append(f' {screen["id"].replace("-", "_")} -->|{mermaid_label(action.get("id", "open"))}| {action["target"].replace("-", "_")}')
158
+ lines += ["```", "", "## Screens", ""]
159
+ for screen in screens:
160
+ lines += [f'### {screen["title"]} (`{screen["id"]}`)', "", f'- presentation: `{screen.get("presentation", "page")}`', f'- platforms: `{", ".join(screen.get("platforms", ["web", "mobile"]))}`', f'- states: `{", ".join(screen.get("states", []))}`', "", "| 顺序 | 元素 ID | 类型 | 内容 |", "|---:|---|---|---|"]
161
+ for i, element in enumerate(screen.get("elements", []), 1):
162
+ payload = element.get("label") or element.get("title") or element.get("type")
163
+ if element.get("fields"): payload = ", ".join(f.get("label", "") for f in element["fields"])
164
+ if element.get("columns"): payload = ", ".join(c.get("label", "") for c in element["columns"])
165
+ if element.get("items"): payload = ", ".join(item.get("title", "") for item in element["items"])
166
+ if element.get("type") == "bottom_navigation" and element.get("items"):
167
+ payload = ", ".join(item.get("label", "") for item in element["items"])
168
+ lines.append(f'| {i} | `{gfm_cell(element["id"])}` | `{gfm_cell(element["type"])}` | {gfm_cell(payload)} |')
169
+ lines += ["", "#### Actions", "", "| ID | Type | Target |", "|---|---|---|"]
170
+ for action in screen.get("actions", []):
171
+ lines.append(f'| `{gfm_cell(action["id"])}` | `{gfm_cell(action["type"])}` | `{gfm_cell(action.get("target") or "null")}` |')
172
+ lines.append("")
173
+ return "\n".join(lines)
174
+
175
+
176
+ def data_uri(svg: str) -> str:
177
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
178
+
179
+
180
+ def gallery_html(manifest: dict[str, Any], assets: dict[str, str], standalone: bool) -> str:
181
+ manifest_json = json.dumps(manifest, ensure_ascii=False, separators=(",", ":"))
182
+ # script is a raw-text element: HTML entities would remain literal and
183
+ # break JSON.parse. Escape only characters that could terminate the tag.
184
+ embedded_manifest = manifest_json.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e")
185
+ asset_json = json.dumps(assets, ensure_ascii=False, separators=(",", ":"))
186
+ return f'''<!doctype html><meta charset="utf-8"><title>Wireframe Gallery</title>
187
+ <style>body{{margin:0;background:#eef2f7;color:#0f172a;font:14px system-ui,sans-serif}}header{{padding:18px 24px;background:#fff;border-bottom:1px solid #cbd5e1}}main{{display:flex;min-height:calc(100vh - 70px)}}aside{{width:190px;padding:20px;background:#f8fafc;border-right:1px solid #cbd5e1}}button{{display:block;width:100%;margin:0 0 8px;padding:10px;text-align:left;border:1px solid #cbd5e1;border-radius:6px;background:#fff;cursor:pointer}}button.active{{border-color:#7c3aed;background:#f3e8ff}}section{{flex:1;padding:24px}}.toolbar{{display:flex;gap:8px;margin-bottom:18px}}.toolbar button{{width:auto}}.frame{{padding:20px;background:#dbe3ed;border-radius:10px;min-height:620px}}.frame img{{display:block;max-width:100%;max-height:620px;margin:auto;box-shadow:0 8px 24px #94a3b855;background:#fff}}.meta{{color:#64748b;font-size:12px;margin-top:12px}}</style>
188
+ <script type="application/json" id="gallery-manifest">{embedded_manifest}</script>
189
+ <header><strong>低保真原型画廊</strong><div class="meta">prototype: {esc(manifest['prototype_fingerprint'])} · source: {esc(manifest['source_document_fingerprint'])} · renderer: {esc(manifest['renderer_version'])}</div></header>
190
+ <main><aside id="nav"></aside><section><div class="toolbar"><button id="web">Web</button><button id="mobile">Mobile</button><button id="flow">流程图</button></div><div class="frame"><img id="preview" alt="页面线框"></div><div class="meta" id="state">state: default</div></section></main>
191
+ <script>const manifest=JSON.parse(document.querySelector('#gallery-manifest').textContent);const assets={asset_json};let current=manifest.screens[0].screen_id,platform='web';const nav=document.querySelector('#nav'),img=document.querySelector('#preview'),webBtn=document.querySelector('#web'),mobileBtn=document.querySelector('#mobile');function path(id,p){{const s=manifest.screens.find(x=>x.screen_id===id);return s&&s.platforms[p]&&s.platforms[p].states.default}}manifest.screens.forEach(s=>{{const b=document.createElement('button');b.textContent=s.screen_id;b.dataset.id=s.screen_id;b.onclick=()=>{{current=s.screen_id;render()}};nav.append(b)}});function render(){{document.querySelectorAll('aside button').forEach(b=>b.classList.toggle('active',b.dataset.id===current));const s=manifest.screens.find(x=>x.screen_id===current);webBtn.disabled=!s.platforms.web;mobileBtn.disabled=!s.platforms.mobile;const p=path(current,platform);if(p){{img.src=assets[p]||p;img.alt=current+' '+platform+' default'}}else{{img.removeAttribute('src');img.alt=current+' '+platform+' 未定义'}}}}webBtn.onclick=()=>{{platform='web';render()}};mobileBtn.onclick=()=>{{platform='mobile';render()}};document.querySelector('#flow').onclick=()=>{{img.src=assets['flow.svg']||'flow.svg'}};render()</script>'''
192
+
193
+
194
+ def main() -> int:
195
+ parser = argparse.ArgumentParser()
196
+ parser.add_argument("--canonical", required=True, type=Path)
197
+ parser.add_argument("--out", required=True, type=Path)
198
+ parser.add_argument("--source-fingerprint", required=True)
199
+ parser.add_argument("--mode", choices=["folder", "standalone", "both"], default="both")
200
+ args = parser.parse_args()
201
+ canonical = json.loads(args.canonical.read_text(encoding="utf-8"))
202
+ screens = canonical.get("screens", [])
203
+ canonical_bytes = json.dumps(canonical_order(normalize(canonical)), ensure_ascii=False, separators=(",", ":")) + "\n"
204
+ prototype = "sha256:" + hashlib.sha256(canonical_bytes.encode("utf-8")).hexdigest()
205
+ manifest: dict[str, Any] = {"prototype_fingerprint": prototype, "source_document_fingerprint": args.source_fingerprint, "renderer_version": "1.0.0", "generated_at": datetime.now(timezone.utc).isoformat(), "screens": []}
206
+ assets: dict[str, str] = {}
207
+ svg_by_path: dict[str, str] = {"flow.svg": render_flow(screens)}
208
+ for screen in screens:
209
+ entry = {"screen_id": screen["id"], "platforms": {}}
210
+ for platform in screen.get("platforms", ["web", "mobile"]):
211
+ path = f"screens/{screen['id']}/{platform}/default.svg"
212
+ svg_by_path[path] = render_screen(screen, platform)
213
+ entry["platforms"][platform] = {"states": {"default": path}}
214
+ manifest["screens"].append(entry)
215
+ args.out.mkdir(parents=True, exist_ok=True)
216
+ (args.out / "preview.md").write_text(render_preview(screens, canonical.get("feature", "wireframe")), encoding="utf-8")
217
+ if args.mode in {"folder", "both"}:
218
+ for path, content in svg_by_path.items():
219
+ target = args.out / path
220
+ target.parent.mkdir(parents=True, exist_ok=True)
221
+ target.write_text(content, encoding="utf-8")
222
+ (args.out / "gallery-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
223
+ (args.out / "wireframes.html").write_text(gallery_html(manifest, {path: path for path in svg_by_path}, False), encoding="utf-8")
224
+ if args.mode in {"standalone", "both"}:
225
+ standalone_assets = {path: data_uri(content) for path, content in svg_by_path.items()}
226
+ (args.out / "wireframes-standalone.html").write_text(gallery_html(manifest, standalone_assets, True), encoding="utf-8")
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ raise SystemExit(main())