sdtk-design-kit 0.1.2 → 0.2.1
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/src/commands/handoff.js +357 -203
- package/src/commands/help.js +2 -2
- package/src/commands/prototype.js +291 -18
- package/src/commands/review.js +298 -1
- package/src/commands/start.js +23 -1
- package/src/lib/anti-slop-lint.js +210 -0
- package/src/lib/component-contract.js +142 -0
- package/src/lib/design-input-contract.js +271 -11
- package/src/lib/design-paths.js +24 -0
- package/src/lib/prototype-briefs.js +125 -0
- package/src/lib/prototype-component-map.js +219 -0
- package/src/lib/prototype-density.js +377 -0
- package/src/lib/prototype-renderer.js +382 -0
- package/src/lib/screen-briefs.js +340 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { briefForScreen, loadPrototypeBriefIndex } = require("./prototype-briefs");
|
|
6
|
+
const { componentClassFor, renderBriefComponent, renderDataSlotPlaceholder } = require("./prototype-component-map");
|
|
7
|
+
const { assessPrototypeDensity, normalizeScreenRole } = require("./prototype-density");
|
|
8
|
+
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return String(value == null ? "" : value)
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, """);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tokenOrDefault(tokens, keyPath, fallback) {
|
|
18
|
+
const value = keyPath.reduce((obj, key) => (obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined), tokens);
|
|
19
|
+
return value == null ? fallback : value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function screenFileName(screen) {
|
|
23
|
+
return `${String(screen.screenId || "screen").replace(/[^a-zA-Z0-9_-]+/g, "-")}.html`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function screenRelativePath(screen) {
|
|
27
|
+
return `docs/design/prototype/screens/${screenFileName(screen)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sharedCss(tokens) {
|
|
31
|
+
return `:root {
|
|
32
|
+
--bg: ${tokenOrDefault(tokens, ["color", "surfaceAlt"], "#F5F7FB")};
|
|
33
|
+
--surface: ${tokenOrDefault(tokens, ["color", "surface"], "#FFFFFF")};
|
|
34
|
+
--text: ${tokenOrDefault(tokens, ["color", "textPrimary"], "#111827")};
|
|
35
|
+
--muted: ${tokenOrDefault(tokens, ["color", "textMuted"], "#64748B")};
|
|
36
|
+
--primary: ${tokenOrDefault(tokens, ["color", "accentPrimary"], "#0F766E")};
|
|
37
|
+
--accent: ${tokenOrDefault(tokens, ["color", "accentSecondary"], "#2563EB")};
|
|
38
|
+
--border: ${tokenOrDefault(tokens, ["color", "border"], "#D8E0EA")};
|
|
39
|
+
--shadow: 0 18px 40px rgba(15,23,42,0.10);
|
|
40
|
+
}
|
|
41
|
+
* { box-sizing: border-box; }
|
|
42
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 ${tokenOrDefault(tokens, ["typography", "fontFamily"], "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif")}; }
|
|
43
|
+
a { color: inherit; }
|
|
44
|
+
.prototype-shell { width: min(1240px, calc(100vw - 24px)); margin: 0 auto; padding: 24px 0 48px; }
|
|
45
|
+
.topbar, .panel, .hero, .launcher-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
|
|
46
|
+
.topbar { position: sticky; top: 0; z-index: 4; padding: 14px; margin-bottom: 18px; }
|
|
47
|
+
.topline, .screen-nav, .button-row, .chip-row, .page-links { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
48
|
+
.topline { justify-content: space-between; }
|
|
49
|
+
.screen-nav { margin-top: 10px; overflow: auto; }
|
|
50
|
+
.screen-nav a, .page-links a, .chip { text-decoration: none; border: 1px solid var(--border); border-radius: 999px; padding: 6px 10px; background: color-mix(in srgb, var(--surface) 94%, var(--bg)); font-size: 12px; font-weight: 750; }
|
|
51
|
+
.meta, .support { color: var(--muted); }
|
|
52
|
+
.eyebrow { margin: 0 0 6px; color: var(--primary); font-size: 12px; font-weight: 850; text-transform: uppercase; letter-spacing: 0; }
|
|
53
|
+
h1, h2, h3, h4, p { margin-top: 0; }
|
|
54
|
+
h1 { font-size: clamp(34px, 6vw, 64px); line-height: 1.04; margin-bottom: 16px; }
|
|
55
|
+
h2 { font-size: clamp(26px, 4vw, 40px); line-height: 1.12; margin-bottom: 12px; }
|
|
56
|
+
h3 { font-size: 18px; margin-bottom: 8px; }
|
|
57
|
+
.launcher-grid, .feature-grid, .product-grid, .metric-grid { display: grid; gap: 14px; }
|
|
58
|
+
.launcher-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
|
59
|
+
.feature-grid, .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
60
|
+
.metric-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
61
|
+
.screen-layout, .split-layout { display: grid; grid-template-columns: minmax(240px, 0.36fr) minmax(0, 1fr); gap: 14px; align-items: start; }
|
|
62
|
+
.panel, .hero, .launcher-card { padding: 16px; }
|
|
63
|
+
.btn { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border-radius: 8px; padding: 0 16px; text-decoration: none; font-weight: 850; border: 1px solid transparent; }
|
|
64
|
+
.btn-primary { background: var(--primary); color: #fff; }
|
|
65
|
+
.btn-secondary { background: transparent; border-color: var(--border); color: var(--text); }
|
|
66
|
+
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
67
|
+
.table th, .table td { border-bottom: 1px solid var(--border); text-align: left; padding: 8px 6px; }
|
|
68
|
+
.form-grid { display: grid; gap: 12px; }
|
|
69
|
+
label { display: grid; gap: 6px; font-weight: 750; }
|
|
70
|
+
input, select, textarea { width: 100%; min-height: 40px; border: 1px solid var(--border); border-radius: 8px; padding: 0 12px; background: #fff; color: var(--text); }
|
|
71
|
+
textarea { padding-top: 10px; min-height: 80px; }
|
|
72
|
+
.qty-stepper { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 8px; padding: 4px 8px; }
|
|
73
|
+
.checkout-stepper { display: flex; gap: 10px; list-style: none; padding: 0; margin: 0 0 14px; flex-wrap: wrap; }
|
|
74
|
+
.checkout-stepper li, .stepper-step { border: 1px solid var(--border); border-radius: 999px; padding: 5px 11px; font-size: 12px; font-weight: 800; background: color-mix(in srgb, var(--primary) 8%, var(--surface)); }
|
|
75
|
+
.timeline { margin: 0; padding-left: 18px; }
|
|
76
|
+
.state-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin: 14px 0; }
|
|
77
|
+
.state-card, .product-card, .feature-card, .category-card, .history-row, .result-row { border: 1px solid var(--border); border-radius: 8px; padding: 12px; background: color-mix(in srgb, var(--surface) 96%, var(--bg)); }
|
|
78
|
+
@media (max-width: 920px) {
|
|
79
|
+
.screen-layout, .split-layout, .feature-grid, .product-grid, .metric-grid { grid-template-columns: 1fr; }
|
|
80
|
+
h1 { font-size: 36px; }
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function staticJs() {
|
|
86
|
+
return `"use strict";
|
|
87
|
+
document.addEventListener("click", (event) => {
|
|
88
|
+
const target = event.target.closest("[data-static-action]");
|
|
89
|
+
if (!target) return;
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
target.setAttribute("data-triggered", "true");
|
|
92
|
+
});
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stateStrip(states) {
|
|
97
|
+
const values = Array.isArray(states) && states.length > 0 ? states : ["loading", "empty", "success", "error"];
|
|
98
|
+
return `<section class="state-strip" aria-label="State coverage"><div class="chip-row">${values
|
|
99
|
+
.map((state) => `<span class="chip" data-chip-type="state" data-state-id="${escapeHtml(state)}">${escapeHtml(state)}</span>`)
|
|
100
|
+
.join("")}</div>${values
|
|
101
|
+
.map((state) => `<article class="state-card ${escapeHtml(state)}-state" data-state-id="${escapeHtml(state)}"><strong>${escapeHtml(state)}</strong><p>Static prototype guidance for the ${escapeHtml(state)} state.</p></article>`)
|
|
102
|
+
.join("")}</section>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function slotLookupForBrief(brief) {
|
|
106
|
+
const lookup = new Map();
|
|
107
|
+
for (const slot of Array.isArray(brief && brief.data_slots) ? brief.data_slots : []) {
|
|
108
|
+
lookup.set(slot.id, slot);
|
|
109
|
+
}
|
|
110
|
+
return lookup;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderNeedsBrief(screen, role) {
|
|
114
|
+
const slots = [
|
|
115
|
+
{ id: "brief-status", label: "Brief status" },
|
|
116
|
+
{ id: "screen-contract", label: "Screen contract" },
|
|
117
|
+
{ id: "source-evidence", label: "Source evidence" },
|
|
118
|
+
{ id: "primary-record", label: "Primary record" },
|
|
119
|
+
{ id: "next-action", label: "Next action" },
|
|
120
|
+
];
|
|
121
|
+
const section = {
|
|
122
|
+
id: "needs-brief-actions",
|
|
123
|
+
title: "Brief recovery actions",
|
|
124
|
+
purpose: "Prototype generation continues with visible review evidence instead of silently falling back to role-default filler.",
|
|
125
|
+
data_slots: slots,
|
|
126
|
+
interactions: ["Review brief", "Regenerate brief", "Continue prototype review"],
|
|
127
|
+
};
|
|
128
|
+
const brief = { screen_title: screen.title, primary_action: "Review brief" };
|
|
129
|
+
return `<section class="panel needs-brief" data-section-id="needs-brief-summary" data-section-role="${escapeHtml(role)}"><p class="eyebrow">NEEDS_BRIEF</p><h2>NEEDS_BRIEF</h2><p class="support">${escapeHtml(screen.title)} requires .sdtk/design/screen-briefs/${escapeHtml(screen.screenId)}.json before high-fidelity composition.</p><div class="chip-row">${slots.map((slot) => renderDataSlotPlaceholder({ slot, section, escapeHtml })).join("")}</div></section><section class="panel" data-section-id="needs-brief-actions" data-section-role="${escapeHtml(role)}"><h2>Brief recovery actions</h2>${renderBriefComponent({ componentId: { id: "primary-panel", label: "Primary panel" }, section, brief, role, slotLookup: new Map(slots.map((slot) => [slot.id, slot])), escapeHtml })}${stateStrip(screen.requiredStates)}</section>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderBriefSlotSummary(brief) {
|
|
133
|
+
const slots = Array.isArray(brief.data_slots) ? brief.data_slots : [];
|
|
134
|
+
if (slots.length === 0) return "";
|
|
135
|
+
return `<div class="panel" data-component-id="screen-data-slots"><h3>Data slots</h3><div class="chip-row">${slots
|
|
136
|
+
.map((slot) => renderDataSlotPlaceholder({ slot, section: { id: "screen-data-slots" }, escapeHtml }))
|
|
137
|
+
.join("")}</div></div>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sectionHasHeroComponent(section) {
|
|
141
|
+
const components = Array.isArray(section && section.components) ? section.components : [];
|
|
142
|
+
return components.some((component) => componentClassFor(component && component.id ? component.id : String(component || "component")) === "hero-section");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function briefHasHeroComponent(brief) {
|
|
146
|
+
return Array.isArray(brief && brief.sections) && brief.sections.some((section) => sectionHasHeroComponent(section));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderBriefSection({ section, brief, role, slotLookup, headingTag = "h2" }) {
|
|
150
|
+
const components = Array.isArray(section.components) && section.components.length > 0 ? section.components : [{ id: "component-placeholder", label: section.title }];
|
|
151
|
+
return `<section class="panel brief-section" data-section-id="${escapeHtml(section.id)}" data-section-role="${escapeHtml(role)}"><p class="eyebrow">${escapeHtml(role.replace(/-/g, " "))}</p><${headingTag}>${escapeHtml(section.title)}</${headingTag}><p class="support">${escapeHtml(section.purpose)}</p><div class="brief-component-stack">${components
|
|
152
|
+
.map((component) => renderBriefComponent({ componentId: component, section, brief, role, slotLookup, escapeHtml }))
|
|
153
|
+
.join("")}</div></section>`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderBriefContent({ screen, role, brief }) {
|
|
157
|
+
if (!brief || !Array.isArray(brief.sections) || brief.sections.length === 0) {
|
|
158
|
+
return renderNeedsBrief(screen, role);
|
|
159
|
+
}
|
|
160
|
+
const slotLookup = slotLookupForBrief(brief);
|
|
161
|
+
const hasHeroComponent = briefHasHeroComponent(brief);
|
|
162
|
+
const sections = brief.sections
|
|
163
|
+
.map((section, index) => renderBriefSection({ section, brief, role, slotLookup, headingTag: !hasHeroComponent && index === 0 ? "h1" : "h2" }))
|
|
164
|
+
.join("");
|
|
165
|
+
return `${sections}${renderBriefSlotSummary(brief)}${stateStrip(brief.states.length > 0 ? brief.states : screen.requiredStates)}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderHome(screen) {
|
|
169
|
+
return `<section class="hero"><p class="eyebrow">Hero</p><h1>${escapeHtml(screen.title)} hero</h1><p class="support">Category-first commerce landing with featured products, clear entry points, and guided configurator access.</p><div class="button-row"><a class="btn btn-primary" href="#" data-static-action>Start purchase</a><a class="btn btn-secondary" href="#" data-static-action>Open configurator</a></div></section>
|
|
170
|
+
<section class="panel" aria-label="Featured products"><h2>Featured products</h2><div class="feature-grid">${["Category range", "Featured products", "Configurator quick start", "Order support"].map((label) => `<article class="feature-card category-card"><h3>${label}</h3><p>Structured entry point with summary, state, and action guidance.</p></article>`).join("")}</div></section>
|
|
171
|
+
${stateStrip(screen.requiredStates)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function productCards(count) {
|
|
175
|
+
return Array.from({ length: count }, (_, index) => `<article class="product-card"><h3>Product card ${index + 1}</h3><p>SKU, availability, comparison attribute, price tier, and primary selection control.</p><div class="button-row"><a class="btn btn-secondary" href="#" data-static-action>Compare</a><a class="btn btn-primary" href="#" data-static-action>Select</a></div></article>`).join("");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderCategory(screen) {
|
|
179
|
+
return `<section class="screen-layout"><aside class="panel"><h3>Filter sidebar</h3>${["Category", "Availability", "Material", "Size", "Price", "Delivery"].map((label) => `<label>${label}<select><option>${label} option</option></select></label>`).join("")}</aside><div class="panel"><h3>Product grid</h3><div class="product-grid">${productCards(8)}</div></div></section>${stateStrip(screen.requiredStates)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderProductDetail(screen) {
|
|
183
|
+
return `<section class="split-layout"><div class="panel"><h3>Gallery and spec area</h3><table class="table spec-table"><tbody>${["Voltage", "Length", "Material", "Finish", "Certification"].map((label, index) => `<tr><th>${label}</th><td>Source-backed specification value ${index + 1}</td></tr>`).join("")}</tbody></table></div><aside class="panel"><h3>Price and quantity</h3><div class="qty-stepper"><button>-</button><span>1</span><button>+</button></div><div class="button-row"><a class="btn btn-primary" href="#" data-static-action>Add to cart</a></div></aside></section>${stateStrip(screen.requiredStates)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderSearch(screen) {
|
|
187
|
+
return `<section class="panel"><h3>Search toolbar</h3><div class="search-toolbar"><input aria-label="Search query" placeholder="Search product"><select><option>Sort by relevance</option></select><button class="btn btn-primary">Search</button></div><div class="result-list">${Array.from({ length: 5 }, (_, index) => `<article class="result-row"><h4>Result item ${index + 1}</h4><p>Matched term, category, availability, and action.</p></article>`).join("")}<article class="result-row no-result"><h4>No-result state</h4><p>Suggest alternate query, category reset, and support route.</p></article></div></section>${stateStrip(screen.requiredStates)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderCart(screen) {
|
|
191
|
+
return `<section class="split-layout"><div class="panel"><h3>Cart table</h3><table class="table cart-table"><thead><tr><th>Item</th><th>Qty</th><th>Unit</th><th>Total</th><th>Action</th></tr></thead><tbody>${Array.from({ length: 4 }, (_, index) => `<tr><td>Line item ${index + 1}</td><td><div class="qty-stepper"><button>-</button><span>${index + 1}</span><button>+</button></div></td><td>1000</td><td>${(index + 1) * 1000}</td><td><button>Remove</button></td></tr>`).join("")}</tbody></table></div><aside class="panel summary-panel"><h3>Summary panel</h3><p>Subtotal, tax, freight, and checkout CTA.</p><a class="btn btn-primary" href="#" data-static-action>Checkout</a></aside></section>${stateStrip(screen.requiredStates)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderCheckout(screen) {
|
|
195
|
+
return `<section class="split-layout"><div class="panel"><h3>Checkout stepper</h3><ol class="checkout-stepper">${["Information", "Delivery", "Payment", "Review"].map((step) => `<li class="stepper-step">${step}</li>`).join("")}</ol><form class="form-grid">${["Recipient", "Address", "Phone", "Purchase order"].map((label) => `<label>${label}<input type="text" value="${label} value"></label>`).join("")}</form></div><aside class="panel summary-panel"><h3>Summary card</h3><p>Order totals, delivery window, and final confirmation.</p><button class="btn btn-primary">Place order</button></aside></section>${stateStrip(screen.requiredStates)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderOrderHistory(screen) {
|
|
199
|
+
return `<section class="panel"><h3>Order history list</h3><table class="table"><thead><tr><th>Order</th><th>Status</th><th>Total</th><th>Action</th></tr></thead><tbody>${Array.from({ length: 5 }, (_, index) => `<tr class="history-row"><td>#10${index}</td><td>Processing</td><td>${(index + 2) * 1200}</td><td><a href="#" data-static-action>Open detail</a></td></tr>`).join("")}</tbody></table></section>${stateStrip(screen.requiredStates)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderOrderDetail(screen) {
|
|
203
|
+
return `<section class="split-layout"><div class="panel"><h3>Timeline</h3><ol class="timeline">${["Placed", "Approved", "Packed", "Shipping", "Delivered"].map((item) => `<li>${item}</li>`).join("")}</ol></div><aside class="panel detail-summary"><h3>Detail summary</h3><p>Delivery, payment, line item, and support snapshot.</p></aside></section>${stateStrip(screen.requiredStates)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderAccountInfo(screen) {
|
|
207
|
+
return `<section class="screen-layout"><aside class="panel"><h3>Account sidebar</h3><nav class="screen-nav"><a href="#">Company</a><a href="#">Users</a><a href="#">Billing</a><a href="#">Security</a></nav></aside><div class="panel"><h3>View or edit form</h3><form class="form-grid">${["Company", "Contact", "Email", "Phone", "Address"].map((label) => `<label>${label}<input type="text" value="${label}"></label>`).join("")}</form></div></section>${stateStrip(screen.requiredStates)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderConfigurator(screen) {
|
|
211
|
+
return `<section class="panel"><h3>Configurator wizard</h3><ol class="checkout-stepper">${["Project", "Assembly", "Construction", "Review", "Complete"].map((step) => `<li class="stepper-step">${step}</li>`).join("")}</ol><div class="split-layout"><div class="panel preview-panel"><h4>Preview panel</h4><p>Derived material preview with recalculation state and validation summary.</p>${stateStrip(screen.requiredStates)}</div><div class="panel"><h4>BOM table</h4><table class="table cart-table"><thead><tr><th>Material</th><th>Spec</th><th>Qty</th><th>Unit</th><th>Action</th></tr></thead><tbody>${Array.from({ length: 8 }, (_, index) => `<tr><td>Material ${index + 1}</td><td>Generated spec ${index + 1}</td><td>${index + 2}</td><td>pcs</td><td><button>Exclude</button></td></tr>`).join("")}</tbody></table><div class="button-row"><a class="btn btn-secondary" href="#" data-static-action>Recalculate</a><a class="btn btn-primary" href="#" data-static-action>Add BOM to cart</a></div></div></div></section>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function renderGeneric(screen) {
|
|
215
|
+
return `<section class="panel"><h3>${escapeHtml(screen.title)} workspace</h3><p>Renderer-ready generic screen with explicit sections, components, states, interactions, and source evidence from the screen brief.</p></section>${stateStrip(screen.requiredStates)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderRoleContent({ screen, role, brief }) {
|
|
219
|
+
if (brief) return renderBriefContent({ screen, role, brief });
|
|
220
|
+
return renderNeedsBrief(screen, role);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderLegacyRoleContent(screen, role) {
|
|
224
|
+
if (role === "home") return renderHome(screen);
|
|
225
|
+
if (role === "category") return renderCategory(screen);
|
|
226
|
+
if (role === "product-detail") return renderProductDetail(screen);
|
|
227
|
+
if (role === "search") return renderSearch(screen);
|
|
228
|
+
if (role === "cart") return renderCart(screen);
|
|
229
|
+
if (role === "checkout") return renderCheckout(screen);
|
|
230
|
+
if (role === "order-history") return renderOrderHistory(screen);
|
|
231
|
+
if (role === "order-detail") return renderOrderDetail(screen);
|
|
232
|
+
if (role === "account-info") return renderAccountInfo(screen);
|
|
233
|
+
if (role === "configurator-bom" || role === "mode-b-configurator") return renderConfigurator(screen);
|
|
234
|
+
return renderGeneric(screen);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function pageChrome({ title, cssHref, bodyContent, backHref, prev, next, screenId, role }) {
|
|
238
|
+
return `<!doctype html>
|
|
239
|
+
<html lang="en">
|
|
240
|
+
<head>
|
|
241
|
+
<meta charset="utf-8">
|
|
242
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
243
|
+
<title>${escapeHtml(title)} Prototype</title>
|
|
244
|
+
<link rel="stylesheet" href="${cssHref}">
|
|
245
|
+
<script src="../assets/prototype.js" defer></script>
|
|
246
|
+
</head>
|
|
247
|
+
<body data-screen-role="${escapeHtml(role)}">
|
|
248
|
+
<main class="prototype-shell" id="screen-${escapeHtml(screenId)}">
|
|
249
|
+
<header class="topbar">
|
|
250
|
+
<div class="topline">
|
|
251
|
+
<a class="btn btn-secondary" href="${backHref}">Back to index</a>
|
|
252
|
+
<strong>${escapeHtml(title)}</strong>
|
|
253
|
+
</div>
|
|
254
|
+
<nav class="page-links" aria-label="Screen page navigation">
|
|
255
|
+
<a href="${prev.href}" rel="prev">Previous: ${escapeHtml(prev.title)}</a>
|
|
256
|
+
<a href="${next.href}" rel="next">Next: ${escapeHtml(next.title)}</a>
|
|
257
|
+
</nav>
|
|
258
|
+
</header>
|
|
259
|
+
${bodyContent}
|
|
260
|
+
</main>
|
|
261
|
+
</body>
|
|
262
|
+
</html>
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function indexHtml({ screens, profile, densityReport, styleName }) {
|
|
267
|
+
return `<!doctype html>
|
|
268
|
+
<html lang="en">
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8">
|
|
271
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
272
|
+
<title>SDTK-DESIGN Prototype Launcher</title>
|
|
273
|
+
<link rel="stylesheet" href="assets/prototype.css">
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<main class="prototype-shell" data-style-preset="${escapeHtml(styleName || "default")}">
|
|
277
|
+
<header class="topbar">
|
|
278
|
+
<div class="topline">
|
|
279
|
+
<strong>SDTK-DESIGN multi-page prototype</strong>
|
|
280
|
+
<span class="meta">screens=${screens.length} | profile=${escapeHtml(profile || "none")} | density=${densityReport.mode === "semantic" ? `${densityReport.aggregate.sectionCount} sections / ${densityReport.aggregate.componentCount} components` : `${densityReport.totalBytes}/${densityReport.minTotalBytes} bytes`}</span>
|
|
281
|
+
</div>
|
|
282
|
+
<nav class="screen-nav" aria-label="Screen navigation">
|
|
283
|
+
${screens.map((screen) => `<a href="screens/${screenFileName(screen)}">${escapeHtml(screen.title)}</a>`).join("")}
|
|
284
|
+
</nav>
|
|
285
|
+
</header>
|
|
286
|
+
<section class="launcher-grid" aria-label="Prototype pages">
|
|
287
|
+
${screens.map((screen) => `<article class="launcher-card"><p class="eyebrow">${escapeHtml(normalizeScreenRole(screen).replace(/-/g, " "))}</p><h2>${escapeHtml(screen.title)}</h2><p class="support">Route: ${escapeHtml(screen.route || "NEEDS_ROUTE")}</p><a class="btn btn-primary" href="screens/${screenFileName(screen)}">Open screen</a></article>`).join("")}
|
|
288
|
+
</section>
|
|
289
|
+
</main>
|
|
290
|
+
</body>
|
|
291
|
+
</html>
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function renderScreenHtml({ screen, role, brief, previousScreen, nextScreen }) {
|
|
296
|
+
const content = renderRoleContent({ screen, role, brief });
|
|
297
|
+
const hasBriefSections = Boolean(brief && Array.isArray(brief.sections) && brief.sections.length > 0);
|
|
298
|
+
const heroHeadingTag = hasBriefSections ? "h2" : "h1";
|
|
299
|
+
return pageChrome({
|
|
300
|
+
title: screen.title,
|
|
301
|
+
cssHref: "../assets/prototype.css",
|
|
302
|
+
backHref: "../index.html",
|
|
303
|
+
prev: { title: previousScreen.title, href: screenFileName(previousScreen) },
|
|
304
|
+
next: { title: nextScreen.title, href: screenFileName(nextScreen) },
|
|
305
|
+
screenId: screen.screenId,
|
|
306
|
+
role,
|
|
307
|
+
bodyContent: `<section class="hero" data-component-id="hero"><p class="eyebrow">${escapeHtml(role.replace(/-/g, " "))}</p><${heroHeadingTag}>${escapeHtml(screen.title)}</${heroHeadingTag}><p class="support">${escapeHtml(screen.purpose || screen.userIntent || "Renderer-ready screen generated from explicit design brief.")}</p></section>${content}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderMultiPagePrototype({ paths, statePayload, contractBundle, styleName, briefIndex }) {
|
|
312
|
+
const screens = statePayload.screenModel.screens;
|
|
313
|
+
const tokens = contractBundle.tokens || {};
|
|
314
|
+
const resolvedBriefIndex = briefIndex || loadPrototypeBriefIndex({ paths });
|
|
315
|
+
fs.mkdirSync(paths.prototypePath, { recursive: true });
|
|
316
|
+
fs.mkdirSync(paths.prototypeScreensPath, { recursive: true });
|
|
317
|
+
fs.mkdirSync(paths.prototypeAssetsPath, { recursive: true });
|
|
318
|
+
|
|
319
|
+
fs.writeFileSync(paths.prototypeCssPath, sharedCss(tokens), "utf-8");
|
|
320
|
+
fs.writeFileSync(paths.prototypeJsPath, staticJs(), "utf-8");
|
|
321
|
+
|
|
322
|
+
const pageRecords = screens.map((screen, index) => {
|
|
323
|
+
const previousScreen = screens[(index - 1 + screens.length) % screens.length];
|
|
324
|
+
const nextScreen = screens[(index + 1) % screens.length];
|
|
325
|
+
const role = normalizeScreenRole({
|
|
326
|
+
...screen,
|
|
327
|
+
template_role:
|
|
328
|
+
contractBundle.screenContracts &&
|
|
329
|
+
contractBundle.screenContracts.get(screen.screenId) &&
|
|
330
|
+
contractBundle.screenContracts.get(screen.screenId).sidecar
|
|
331
|
+
? contractBundle.screenContracts.get(screen.screenId).sidecar.template_role
|
|
332
|
+
: null,
|
|
333
|
+
});
|
|
334
|
+
const screenContract =
|
|
335
|
+
contractBundle.screenContracts && contractBundle.screenContracts.get(screen.screenId)
|
|
336
|
+
? contractBundle.screenContracts.get(screen.screenId)
|
|
337
|
+
: null;
|
|
338
|
+
const brief = briefForScreen({ screen, briefIndex: resolvedBriefIndex }) || (screenContract && screenContract.sidecar ? screenContract.sidecar : null);
|
|
339
|
+
const html = renderScreenHtml({ screen, role, brief, previousScreen, nextScreen });
|
|
340
|
+
return {
|
|
341
|
+
screenId: screen.screenId,
|
|
342
|
+
title: screen.title,
|
|
343
|
+
role,
|
|
344
|
+
brief,
|
|
345
|
+
relativePath: screenRelativePath(screen),
|
|
346
|
+
filePath: path.join(paths.prototypeScreensPath, screenFileName(screen)),
|
|
347
|
+
html,
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const densityReport = assessPrototypeDensity(pageRecords);
|
|
352
|
+
for (const page of pageRecords) {
|
|
353
|
+
fs.writeFileSync(page.filePath, page.html, "utf-8");
|
|
354
|
+
}
|
|
355
|
+
fs.writeFileSync(
|
|
356
|
+
paths.prototypeIndexPath,
|
|
357
|
+
indexHtml({ screens, profile: statePayload.profileSelection || "generic", densityReport, styleName }),
|
|
358
|
+
"utf-8"
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
mode: "high-fidelity-multi-page",
|
|
363
|
+
indexRelativePath: "docs/design/prototype/index.html",
|
|
364
|
+
assetRelativePaths: ["docs/design/prototype/assets/prototype.css", "docs/design/prototype/assets/prototype.js"],
|
|
365
|
+
screenPages: pageRecords.map((page) => ({
|
|
366
|
+
screenId: page.screenId,
|
|
367
|
+
title: page.title,
|
|
368
|
+
role: page.role,
|
|
369
|
+
relativePath: page.relativePath,
|
|
370
|
+
byteLength: Buffer.byteLength(page.html, "utf8"),
|
|
371
|
+
})),
|
|
372
|
+
densityReport,
|
|
373
|
+
briefFindings: resolvedBriefIndex.findings || [],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
renderMultiPagePrototype,
|
|
379
|
+
renderLegacyRoleContent,
|
|
380
|
+
screenFileName,
|
|
381
|
+
screenRelativePath,
|
|
382
|
+
};
|