@forwardimpact/pathway 0.3.0 → 0.5.0

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 (90) hide show
  1. package/app/commands/agent.js +1 -1
  2. package/app/commands/behaviour.js +1 -1
  3. package/app/commands/command-factory.js +2 -2
  4. package/app/commands/discipline.js +1 -1
  5. package/app/commands/driver.js +1 -1
  6. package/app/commands/grade.js +1 -1
  7. package/app/commands/index.js +4 -3
  8. package/app/commands/serve.js +2 -2
  9. package/app/commands/site.js +22 -2
  10. package/app/commands/skill.js +57 -3
  11. package/app/commands/stage.js +1 -1
  12. package/app/commands/tool.js +112 -0
  13. package/app/commands/track.js +1 -1
  14. package/app/components/card.js +11 -1
  15. package/app/components/checklist.js +6 -4
  16. package/app/components/code-display.js +153 -0
  17. package/app/components/markdown-textarea.js +153 -0
  18. package/app/css/bundles/app.css +14 -0
  19. package/app/css/components/badges.css +15 -8
  20. package/app/css/components/forms.css +55 -0
  21. package/app/css/components/layout.css +12 -0
  22. package/app/css/components/surfaces.css +71 -3
  23. package/app/css/components/typography.css +1 -2
  24. package/app/css/pages/agent-builder.css +11 -102
  25. package/app/css/pages/detail.css +60 -0
  26. package/app/css/pages/job-builder.css +0 -42
  27. package/app/css/tokens.css +3 -0
  28. package/app/formatters/agent/dom.js +26 -71
  29. package/app/formatters/agent/profile.js +67 -10
  30. package/app/formatters/agent/skill.js +48 -6
  31. package/app/formatters/grade/dom.js +6 -6
  32. package/app/formatters/job/description.js +21 -16
  33. package/app/formatters/job/dom.js +9 -70
  34. package/app/formatters/json-ld.js +1 -1
  35. package/app/formatters/shared.js +58 -0
  36. package/app/formatters/skill/dom.js +70 -3
  37. package/app/formatters/skill/markdown.js +18 -0
  38. package/app/formatters/skill/shared.js +14 -4
  39. package/app/formatters/stage/microdata.js +2 -2
  40. package/app/formatters/stage/shared.js +3 -3
  41. package/app/formatters/tool/shared.js +78 -0
  42. package/app/handout-main.js +19 -18
  43. package/app/index.html +16 -3
  44. package/app/lib/card-mappers.js +91 -17
  45. package/app/lib/render.js +4 -0
  46. package/app/lib/yaml-loader.js +12 -1
  47. package/app/main.js +4 -0
  48. package/app/model/agent.js +47 -23
  49. package/app/model/checklist.js +2 -2
  50. package/app/model/derivation.js +5 -5
  51. package/app/model/levels.js +4 -2
  52. package/app/model/loader.js +12 -1
  53. package/app/model/validation.js +77 -11
  54. package/app/pages/agent-builder.js +121 -77
  55. package/app/pages/landing.js +35 -15
  56. package/app/pages/self-assessment.js +7 -5
  57. package/app/pages/skill.js +5 -17
  58. package/app/pages/stage.js +12 -8
  59. package/app/pages/tool.js +50 -0
  60. package/app/slide-main.js +1 -1
  61. package/app/slides/chapter.js +8 -8
  62. package/app/slides/index.js +26 -26
  63. package/app/slides/overview.js +8 -8
  64. package/app/slides/skill.js +1 -0
  65. package/bin/pathway.js +31 -16
  66. package/examples/capabilities/business.yaml +18 -18
  67. package/examples/capabilities/delivery.yaml +54 -37
  68. package/examples/capabilities/people.yaml +1 -1
  69. package/examples/capabilities/reliability.yaml +130 -115
  70. package/examples/capabilities/scale.yaml +39 -37
  71. package/examples/disciplines/engineering_management.yaml +1 -1
  72. package/examples/framework.yaml +21 -9
  73. package/examples/grades.yaml +5 -7
  74. package/examples/self-assessments.yaml +1 -1
  75. package/examples/stages.yaml +18 -10
  76. package/package.json +2 -1
  77. package/templates/agent.template.md +47 -17
  78. package/templates/job.template.md +8 -8
  79. package/templates/skill.template.md +33 -11
  80. package/examples/agents/.claude/skills/architecture-design/SKILL.md +0 -130
  81. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +0 -131
  82. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +0 -108
  83. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +0 -142
  84. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +0 -134
  85. package/examples/agents/.claude/skills/sre-practices/SKILL.md +0 -163
  86. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +0 -164
  87. package/examples/agents/.github/agents/se-platform-code.agent.md +0 -132
  88. package/examples/agents/.github/agents/se-platform-plan.agent.md +0 -131
  89. package/examples/agents/.github/agents/se-platform-review.agent.md +0 -136
  90. package/examples/agents/.vscode/settings.json +0 -8
@@ -89,46 +89,4 @@
89
89
  font-weight: 600;
90
90
  color: var(--color-primary);
91
91
  }
92
-
93
- /* Job description section */
94
- .job-description-container {
95
- display: flex;
96
- flex-direction: column;
97
- gap: var(--space-md);
98
- }
99
-
100
- .job-description-header {
101
- display: flex;
102
- justify-content: space-between;
103
- align-items: center;
104
- flex-wrap: wrap;
105
- gap: var(--space-md);
106
- }
107
-
108
- .job-description-header .text-muted {
109
- margin: 0;
110
- flex: 1;
111
- min-width: 200px;
112
- }
113
-
114
- .job-description-textarea {
115
- width: 100%;
116
- min-height: 400px;
117
- padding: var(--space-md);
118
- font-family:
119
- ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
120
- font-size: var(--font-size-sm);
121
- line-height: 1.6;
122
- background-color: var(--color-bg);
123
- border: 1px solid var(--color-border);
124
- border-radius: var(--radius-md);
125
- resize: vertical;
126
- color: var(--color-text);
127
- }
128
-
129
- .job-description-textarea:focus {
130
- outline: none;
131
- border-color: var(--color-primary);
132
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
133
- }
134
92
  }
@@ -130,6 +130,9 @@
130
130
  --font-family:
131
131
  system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
132
132
  sans-serif;
133
+ --font-family-mono:
134
+ "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo,
135
+ Consolas, monospace;
133
136
  --font-size-xs: 0.75rem;
134
137
  --font-size-sm: 0.875rem;
135
138
  --font-size-base: 1rem;
@@ -16,6 +16,7 @@ import {
16
16
  details,
17
17
  summary,
18
18
  } from "../../lib/render.js";
19
+ import { createCodeDisplay } from "../../components/code-display.js";
19
20
  import { formatAgentProfile } from "./profile.js";
20
21
  import { formatAgentSkill } from "./skill.js";
21
22
  import { getStageEmoji } from "../stage/shared.js";
@@ -53,13 +54,12 @@ export function agentDeploymentToDOM({
53
54
  // Profile section
54
55
  section(
55
56
  { className: "agent-section" },
56
- div(
57
- { className: "section-header" },
58
- h2({}, "Agent Profile"),
59
- createCopyButton(profileContent),
60
- ),
61
- p({ className: "filename" }, profile.filename),
62
- createCodeBlock(profileContent),
57
+ h2({}, "Agent Profile"),
58
+ createCodeDisplay({
59
+ content: profileContent,
60
+ filename: profile.filename,
61
+ maxHeight: 600,
62
+ }),
63
63
  ),
64
64
 
65
65
  // Role Agents section
@@ -145,48 +145,6 @@ function createDownloadButton(
145
145
  return btn;
146
146
  }
147
147
 
148
- /**
149
- * Create a copy button for content
150
- * @param {string} content - Content to copy
151
- * @returns {HTMLElement}
152
- */
153
- function createCopyButton(content) {
154
- const btn = button({ className: "btn btn-sm copy-btn" }, "📋 Copy");
155
-
156
- btn.addEventListener("click", async () => {
157
- try {
158
- await navigator.clipboard.writeText(content);
159
- btn.textContent = "✓ Copied";
160
- setTimeout(() => {
161
- btn.textContent = "📋 Copy";
162
- }, 2000);
163
- } catch {
164
- btn.textContent = "Failed";
165
- setTimeout(() => {
166
- btn.textContent = "📋 Copy";
167
- }, 2000);
168
- }
169
- });
170
-
171
- return btn;
172
- }
173
-
174
- /**
175
- * Create a code block with content
176
- * @param {string} content - Code content
177
- * @returns {HTMLElement}
178
- */
179
- function createCodeBlock(content) {
180
- const pre = document.createElement("pre");
181
- pre.className = "code-block";
182
-
183
- const code = document.createElement("code");
184
- code.textContent = content;
185
-
186
- pre.appendChild(code);
187
- return pre;
188
- }
189
-
190
148
  /**
191
149
  * Create a skill card with content and copy button
192
150
  * @param {Object} skill - Skill with frontmatter and body
@@ -198,12 +156,11 @@ function createSkillCard(skill) {
198
156
 
199
157
  return div(
200
158
  { className: "skill-card" },
201
- div(
202
- { className: "skill-header" },
203
- span({ className: "skill-filename" }, filename),
204
- createCopyButton(content),
205
- ),
206
- createCodeBlock(content),
159
+ createCodeDisplay({
160
+ content,
161
+ filename,
162
+ maxHeight: 300,
163
+ }),
207
164
  );
208
165
  }
209
166
 
@@ -235,8 +192,10 @@ function createRoleAgentCard(agent) {
235
192
  { className: "text-muted role-description" },
236
193
  agent.frontmatter.description,
237
194
  ),
238
- div({ className: "role-agent-actions" }, createCopyButton(content)),
239
- createCodeBlock(content),
195
+ createCodeDisplay({
196
+ content,
197
+ maxHeight: 400,
198
+ }),
240
199
  ),
241
200
  );
242
201
  }
@@ -254,13 +213,10 @@ function createCliCommand(agentName) {
254
213
 
255
214
  const command = `npx pathway agent ${discipline} ${track} --output=.github --all-roles`;
256
215
 
257
- const container = div(
258
- { className: "cli-command" },
259
- createCodeBlock(command),
260
- createCopyButton(command),
261
- );
262
-
263
- return container;
216
+ return createCodeDisplay({
217
+ content: command,
218
+ language: "bash",
219
+ });
264
220
  }
265
221
 
266
222
  /**
@@ -445,13 +401,12 @@ export function stageAgentToDOM(stageAgent, profile, options = {}) {
445
401
  // Profile section
446
402
  section(
447
403
  { className: "agent-section" },
448
- div(
449
- { className: "section-header" },
450
- h3({}, "Agent Profile"),
451
- createCopyButton(profileContent),
452
- ),
453
- p({ className: "filename" }, profile.filename),
454
- createCodeBlock(profileContent),
404
+ h3({}, "Agent Profile"),
405
+ createCodeDisplay({
406
+ content: profileContent,
407
+ filename: profile.filename,
408
+ maxHeight: 600,
409
+ }),
455
410
  ),
456
411
 
457
412
  // Download button
@@ -10,6 +10,71 @@
10
10
 
11
11
  import Mustache from "mustache";
12
12
 
13
+ import { trimValue, trimRequired, trimFields } from "../shared.js";
14
+
15
+ /**
16
+ * Prepare agent profile data for template rendering
17
+ * Normalizes string values by trimming trailing newlines for consistent template output.
18
+ * @param {Object} params
19
+ * @param {Object} params.frontmatter - YAML frontmatter data
20
+ * @param {string} params.frontmatter.name - Agent name
21
+ * @param {string} params.frontmatter.description - Agent description
22
+ * @param {boolean} params.frontmatter.infer - Whether to auto-select
23
+ * @param {Array} [params.frontmatter.handoffs] - Handoff definitions
24
+ * @param {Object} params.bodyData - Structured body data
25
+ * @param {string} params.bodyData.title - Agent title
26
+ * @param {string} params.bodyData.stageDescription - Stage description text
27
+ * @param {string} params.bodyData.identity - Core identity text
28
+ * @param {string} [params.bodyData.priority] - Priority/philosophy statement
29
+ * @param {Array<{name: string, dirname: string, useWhen: string}>} params.bodyData.skillIndex - Skill index entries
30
+ * @param {Array<{index: number, text: string}>} params.bodyData.beforeMakingChanges - Numbered steps
31
+ * @param {string} [params.bodyData.delegation] - Delegation guidance
32
+ * @param {string} params.bodyData.operationalContext - Operational context text
33
+ * @param {string} params.bodyData.workingStyle - Working style markdown section
34
+ * @param {string} [params.bodyData.beforeHandoff] - Before handoff checklist markdown
35
+ * @param {string[]} params.bodyData.constraints - List of constraints
36
+ * @returns {Object} Data object ready for Mustache template
37
+ */
38
+ function prepareAgentProfileData({ frontmatter, bodyData }) {
39
+ // Trim array fields using helpers
40
+ const handoffs = trimFields(frontmatter.handoffs, { prompt: "required" });
41
+ const beforeMakingChanges = trimFields(bodyData.beforeMakingChanges, {
42
+ text: "required",
43
+ });
44
+
45
+ // Trim simple string arrays
46
+ const constraints = (bodyData.constraints || []).map((c) => trimRequired(c));
47
+
48
+ // Trim skill index entries
49
+ const skillIndex = (bodyData.skillIndex || []).map((s) => ({
50
+ name: trimRequired(s.name),
51
+ dirname: trimRequired(s.dirname),
52
+ useWhen: trimRequired(s.useWhen),
53
+ }));
54
+
55
+ return {
56
+ // Frontmatter
57
+ name: frontmatter.name,
58
+ description: trimRequired(frontmatter.description),
59
+ infer: frontmatter.infer,
60
+ handoffs,
61
+
62
+ // Body data - trim all string fields
63
+ title: bodyData.title,
64
+ stageDescription: trimValue(bodyData.stageDescription),
65
+ identity: trimValue(bodyData.identity),
66
+ priority: trimValue(bodyData.priority),
67
+ skillIndex,
68
+ hasSkills: skillIndex.length > 0,
69
+ beforeMakingChanges,
70
+ delegation: trimValue(bodyData.delegation),
71
+ operationalContext: trimValue(bodyData.operationalContext),
72
+ workingStyle: trimValue(bodyData.workingStyle),
73
+ beforeHandoff: trimValue(bodyData.beforeHandoff),
74
+ constraints,
75
+ };
76
+ }
77
+
13
78
  /**
14
79
  * Format agent profile as .agent.md file content using Mustache template
15
80
  * @param {Object} profile - Profile with frontmatter and bodyData
@@ -24,7 +89,7 @@ import Mustache from "mustache";
24
89
  * @param {string} profile.bodyData.stageDescription - Stage description text
25
90
  * @param {string} profile.bodyData.identity - Core identity text
26
91
  * @param {string} [profile.bodyData.priority] - Priority/philosophy statement (optional)
27
- * @param {string[]} profile.bodyData.capabilities - List of capability names
92
+ * @param {Array<{name: string, dirname: string, useWhen: string}>} profile.bodyData.skillIndex - Skill index entries
28
93
  * @param {Array<{index: number, text: string}>} profile.bodyData.beforeMakingChanges - Numbered steps
29
94
  * @param {string} [profile.bodyData.delegation] - Delegation guidance (optional)
30
95
  * @param {string} profile.bodyData.operationalContext - Operational context text
@@ -35,14 +100,6 @@ import Mustache from "mustache";
35
100
  * @returns {string} Complete .agent.md file content
36
101
  */
37
102
  export function formatAgentProfile({ frontmatter, bodyData }, template) {
38
- const data = {
39
- // Frontmatter
40
- name: frontmatter.name,
41
- description: frontmatter.description,
42
- infer: frontmatter.infer,
43
- handoffs: frontmatter.handoffs || [],
44
- // Body data
45
- ...bodyData,
46
- };
103
+ const data = prepareAgentProfileData({ frontmatter, bodyData });
47
104
  return Mustache.render(template, data);
48
105
  }
@@ -10,6 +10,47 @@
10
10
 
11
11
  import Mustache from "mustache";
12
12
 
13
+ import { trimValue, splitLines, trimFields } from "../shared.js";
14
+
15
+ /**
16
+ * Prepare agent skill data for template rendering
17
+ * Normalizes string values by trimming trailing newlines for consistent template output.
18
+ * @param {Object} params
19
+ * @param {Object} params.frontmatter - YAML frontmatter data
20
+ * @param {string} params.frontmatter.name - Skill name (required)
21
+ * @param {string} params.frontmatter.description - Skill description (required)
22
+ * @param {string} [params.frontmatter.useWhen] - When to use this skill
23
+ * @param {string} params.title - Human-readable skill title for heading
24
+ * @param {Array} params.stages - Array of stage objects with stageName, focus, activities, ready
25
+ * @param {string} params.reference - Reference content (markdown)
26
+ * @param {Array} [params.toolReferences] - Array of tool reference objects
27
+ * @returns {Object} Data object ready for Mustache template
28
+ */
29
+ function prepareAgentSkillData({
30
+ frontmatter,
31
+ title,
32
+ stages,
33
+ reference,
34
+ toolReferences,
35
+ }) {
36
+ // Process stages - trim focus and array values
37
+ const processedStages = trimFields(stages, {
38
+ focus: "required",
39
+ activities: "array",
40
+ ready: "array",
41
+ });
42
+
43
+ return {
44
+ name: frontmatter.name,
45
+ descriptionLines: splitLines(frontmatter.description),
46
+ useWhenLines: splitLines(frontmatter.useWhen),
47
+ title,
48
+ stages: processedStages,
49
+ reference: trimValue(reference) || "",
50
+ toolReferences: toolReferences || [],
51
+ };
52
+ }
53
+
13
54
  /**
14
55
  * Format agent skill as SKILL.md file content using Mustache template
15
56
  * @param {Object} skill - Skill with frontmatter, title, stages, reference
@@ -19,19 +60,20 @@ import Mustache from "mustache";
19
60
  * @param {string} skill.title - Human-readable skill title for heading
20
61
  * @param {Array} skill.stages - Array of stage objects with stageName, focus, activities, ready
21
62
  * @param {string} skill.reference - Reference content (markdown)
63
+ * @param {Array} [skill.toolReferences] - Array of tool reference objects
22
64
  * @param {string} template - Mustache template string
23
65
  * @returns {string} Complete SKILL.md file content
24
66
  */
25
67
  export function formatAgentSkill(
26
- { frontmatter, title, stages, reference },
68
+ { frontmatter, title, stages, reference, toolReferences },
27
69
  template,
28
70
  ) {
29
- const data = {
30
- name: frontmatter.name,
31
- descriptionLines: frontmatter.description.trim().split("\n"),
71
+ const data = prepareAgentSkillData({
72
+ frontmatter,
32
73
  title,
33
74
  stages,
34
- reference: reference ? reference.trim() : "",
35
- };
75
+ reference,
76
+ toolReferences,
77
+ });
36
78
  return Mustache.render(template, data);
37
79
  }
@@ -77,16 +77,16 @@ export function gradeToDOM(grade, { framework, showBackLink = true } = {}) {
77
77
  { className: "content-columns" },
78
78
  view.professionalTitle
79
79
  ? div(
80
- { className: "list-item" },
80
+ { className: "card" },
81
81
  p({ className: "label" }, "Professional Track"),
82
- p({}, view.professionalTitle),
82
+ p({ className: "card-description" }, view.professionalTitle),
83
83
  )
84
84
  : null,
85
85
  view.managementTitle
86
86
  ? div(
87
- { className: "list-item" },
87
+ { className: "card" },
88
88
  p({ className: "label" }, "Management Track"),
89
- p({}, view.managementTitle),
89
+ p({ className: "card-description" }, view.managementTitle),
90
90
  )
91
91
  : null,
92
92
  ),
@@ -102,9 +102,9 @@ export function gradeToDOM(grade, { framework, showBackLink = true } = {}) {
102
102
  { className: "content-columns" },
103
103
  ...Object.entries(view.expectations).map(([key, value]) =>
104
104
  div(
105
- { className: "list-item" },
105
+ { className: "card" },
106
106
  p({ className: "label" }, formatLevel(key)),
107
- p({}, value),
107
+ p({ className: "card-description" }, value),
108
108
  ),
109
109
  ),
110
110
  ),
@@ -14,6 +14,7 @@ import {
14
14
  SKILL_LEVEL_ORDER,
15
15
  BEHAVIOUR_MATURITY_ORDER,
16
16
  } from "../../model/levels.js";
17
+ import { trimValue, trimFields } from "../shared.js";
17
18
 
18
19
  /**
19
20
  * Prepare job data for template rendering
@@ -121,28 +122,32 @@ function prepareJobDescriptionData({ job, discipline, grade, track }) {
121
122
  };
122
123
  });
123
124
 
125
+ // Build qualification summary with placeholder replacement
126
+ const qualificationSummary =
127
+ (grade.qualificationSummary || "").replace(
128
+ /\{typicalExperienceRange\}/g,
129
+ grade.typicalExperienceRange || "",
130
+ ) || null;
131
+
124
132
  return {
125
133
  title: job.title,
126
134
  gradeId: grade.id,
127
135
  typicalExperienceRange: grade.typicalExperienceRange,
128
136
  trackName: track?.name || null,
129
- roleSummary,
130
- trackRoleContext: track?.roleContext || null,
131
- expectationsParagraph: expectationsParagraph || null,
132
- responsibilities: (job.derivedResponsibilities || []).map((r) => ({
133
- capabilityName: r.capabilityName,
134
- responsibility: r.responsibility,
135
- })),
136
- behaviours: sortedBehaviours.map((b) => ({
137
- behaviourName: b.behaviourName,
138
- maturityDescription: b.maturityDescription || "",
137
+ roleSummary: trimValue(roleSummary),
138
+ trackRoleContext: trimValue(track?.roleContext),
139
+ expectationsParagraph: trimValue(expectationsParagraph),
140
+ responsibilities: trimFields(job.derivedResponsibilities, {
141
+ responsibility: "required",
142
+ }),
143
+ behaviours: trimFields(sortedBehaviours, {
144
+ maturityDescription: "optional",
145
+ }),
146
+ skillLevels: skillLevels.map((level) => ({
147
+ ...level,
148
+ skills: trimFields(level.skills, { levelDescription: "optional" }),
139
149
  })),
140
- skillLevels,
141
- qualificationSummary:
142
- (grade.qualificationSummary || "").replace(
143
- /\{typicalExperienceRange\}/g,
144
- grade.typicalExperienceRange || "",
145
- ) || null,
150
+ qualificationSummary: trimValue(qualificationSummary),
146
151
  };
147
152
  }
148
153
 
@@ -2,7 +2,7 @@
2
2
  * Job formatting for DOM/web output
3
3
  */
4
4
 
5
- import { div, h1, h2, p, a, span, button, section } from "../../lib/render.js";
5
+ import { div, h1, h2, p, a, span, section } from "../../lib/render.js";
6
6
  import { createBackLink } from "../../components/nav.js";
7
7
  import {
8
8
  createDetailSection,
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../components/radar-chart.js";
15
15
  import { createSkillMatrix } from "../../components/skill-matrix.js";
16
16
  import { createBehaviourProfile } from "../../components/behaviour-profile.js";
17
+ import { createCodeDisplay } from "../../components/code-display.js";
17
18
  import { markdownToHtml } from "../../lib/markdown.js";
18
19
  import { formatJobDescription } from "./description.js";
19
20
 
@@ -223,77 +224,15 @@ export function createJobDescriptionSection({
223
224
  template,
224
225
  );
225
226
 
226
- const copyButton = button(
227
- {
228
- className: "btn btn-primary copy-btn",
229
- onClick: async () => {
230
- try {
231
- await navigator.clipboard.writeText(markdown);
232
- copyButton.textContent = "✓ Copied!";
233
- copyButton.classList.add("copied");
234
- setTimeout(() => {
235
- copyButton.textContent = "Copy Markdown";
236
- copyButton.classList.remove("copied");
237
- }, 2000);
238
- } catch (err) {
239
- console.error("Failed to copy:", err);
240
- copyButton.textContent = "Copy failed";
241
- setTimeout(() => {
242
- copyButton.textContent = "Copy Markdown";
243
- }, 2000);
244
- }
245
- },
246
- },
247
- "Copy Markdown",
248
- );
249
-
250
- const copyHtmlButton = button(
251
- {
252
- className: "btn btn-secondary copy-btn",
253
- onClick: async () => {
254
- try {
255
- const html = markdownToHtml(markdown);
256
- // Use ClipboardItem with text/html MIME type for rich text pasting in Word
257
- const blob = new Blob([html], { type: "text/html" });
258
- const clipboardItem = new ClipboardItem({ "text/html": blob });
259
- await navigator.clipboard.write([clipboardItem]);
260
- copyHtmlButton.textContent = "✓ Copied!";
261
- copyHtmlButton.classList.add("copied");
262
- setTimeout(() => {
263
- copyHtmlButton.textContent = "Copy as HTML";
264
- copyHtmlButton.classList.remove("copied");
265
- }, 2000);
266
- } catch (err) {
267
- console.error("Failed to copy:", err);
268
- copyHtmlButton.textContent = "Copy failed";
269
- setTimeout(() => {
270
- copyHtmlButton.textContent = "Copy as HTML";
271
- }, 2000);
272
- }
273
- },
274
- },
275
- "Copy as HTML",
276
- );
277
-
278
- const textarea = document.createElement("textarea");
279
- textarea.className = "job-description-textarea";
280
- textarea.readOnly = true;
281
- textarea.value = markdown;
282
-
283
227
  return createDetailSection({
284
228
  title: "Job Description",
285
- content: div(
286
- { className: "job-description-container" },
287
- div(
288
- { className: "job-description-header" },
289
- p(
290
- { className: "text-muted" },
291
- "Copy this markdown-formatted job description for use in job postings, documentation, or sharing.",
292
- ),
293
- div({ className: "button-group" }, copyButton, copyHtmlButton),
294
- ),
295
- textarea,
296
- ),
229
+ content: createCodeDisplay({
230
+ content: markdown,
231
+ description:
232
+ "Copy this markdown-formatted job description for use in job postings, documentation, or sharing.",
233
+ toHtml: markdownToHtml,
234
+ minHeight: 450,
235
+ }),
297
236
  });
298
237
  }
299
238
 
@@ -228,7 +228,7 @@ export function stageToJsonLd(stage) {
228
228
  identifier: stage.id,
229
229
  name: stage.name,
230
230
  description: stage.description,
231
- ...(stage.emoji && { emoji: stage.emoji }),
231
+ ...(stage.emojiIcon && { emojiIcon: stage.emojiIcon }),
232
232
  ...(stage.tools?.length > 0 && { tools: stage.tools }),
233
233
  ...(stage.constraints?.length > 0 && { constraints: stage.constraints }),
234
234
  ...(stage.handoffs && {
@@ -4,6 +4,64 @@
4
4
  * Common formatting functions used across different output formats (CLI, DOM, markdown)
5
5
  */
6
6
 
7
+ /**
8
+ * Trim trailing newlines from a string value
9
+ * Used by template prepare functions for consistent output formatting.
10
+ * @param {string|null|undefined} value - Value to trim
11
+ * @returns {string|null} Trimmed value or null if empty
12
+ */
13
+ export function trimValue(value) {
14
+ if (value == null) return null;
15
+ const trimmed = value.replace(/\n+$/, "");
16
+ return trimmed || null;
17
+ }
18
+
19
+ /**
20
+ * Trim a required field, preserving original if trim would result in empty
21
+ * Use for fields that must have a value.
22
+ * @param {string|null|undefined} value - Value to trim
23
+ * @returns {string} Trimmed value or original
24
+ */
25
+ export function trimRequired(value) {
26
+ return trimValue(value) || value || "";
27
+ }
28
+
29
+ /**
30
+ * Trim and split a string into lines
31
+ * @param {string|null|undefined} value - Value to process
32
+ * @returns {string[]} Array of lines (empty array if no value)
33
+ */
34
+ export function splitLines(value) {
35
+ const trimmed = trimValue(value);
36
+ return trimmed ? trimmed.split("\n") : [];
37
+ }
38
+
39
+ /**
40
+ * Transform an array of objects by applying trimValue to specified fields
41
+ * @param {Array<Object>} array - Array of objects to transform
42
+ * @param {Object<string, 'optional'|'required'|'array'>} fieldSpec - Fields to trim and their type
43
+ * - 'optional': use trimValue (returns null if empty)
44
+ * - 'required': use trimRequired (preserves original if empty)
45
+ * - 'array': trim each element in array field
46
+ * @returns {Array<Object>} Transformed array
47
+ */
48
+ export function trimFields(array, fieldSpec) {
49
+ if (!array) return [];
50
+ return array.map((item) => {
51
+ const result = { ...item };
52
+ for (const [field, type] of Object.entries(fieldSpec)) {
53
+ if (type === "optional") {
54
+ result[field] = trimValue(item[field]);
55
+ } else if (type === "required") {
56
+ result[field] = trimRequired(item[field]);
57
+ } else if (type === "array") {
58
+ result[field] = (item[field] || []).map((v) => trimRequired(v));
59
+ }
60
+ }
61
+ return result;
62
+ });
63
+ }
64
+
7
65
  /**
8
66
  * Format level as text with dots (for CLI/markdown)
9
67
  * @param {number} level - 1-5