@guanlin4924/devflow-pi 0.1.39 → 0.1.47
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.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +61 -24
|
@@ -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())
|