fraim 2.0.165 → 2.0.167
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/dist/src/ai-hub/catalog.js +20 -27
- package/dist/src/ai-hub/server.js +418 -2
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/org.js +40 -0
- package/dist/src/cli/commands/test-mcp.js +171 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/setup/first-run.js +242 -0
- package/dist/src/cli/utils/org-publish.js +98 -0
- package/dist/src/config/ai-manager-hiring.js +121 -0
- package/dist/src/config/compat.js +16 -0
- package/dist/src/config/feature-flags.js +25 -0
- package/dist/src/config/persona-capability-bundles.js +273 -0
- package/dist/src/config/persona-hiring.js +270 -0
- package/dist/src/config/portfolio-slug-overrides.js +17 -0
- package/dist/src/config/pricing.js +37 -0
- package/dist/src/config/stripe.js +43 -0
- package/dist/src/core/config-loader.js +9 -5
- package/dist/src/core/config-writer.js +75 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -21
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/local-registry-resolver.js +8 -1
- package/dist/src/core/utils/workflow-parser.js +174 -0
- package/index.js +1 -1
- package/package.json +5 -1
- package/public/ai-hub/index.html +81 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
- package/public/ai-hub/review.css +13 -0
- package/public/ai-hub/script.js +414 -4
- package/public/ai-hub/styles.css +56 -0
- package/public/first-run/styles.css +73 -73
- package/public/portfolio/ashley.html +523 -0
- package/public/portfolio/auditya.html +83 -0
- package/public/portfolio/banke.html +83 -0
- package/public/portfolio/beza.html +659 -0
- package/public/portfolio/careena.html +632 -0
- package/public/portfolio/casey.html +568 -0
- package/public/portfolio/celia.html +490 -0
- package/public/portfolio/deidre.html +642 -0
- package/public/portfolio/gautam.html +597 -0
- package/public/portfolio/hari.html +469 -0
- package/public/portfolio/huxley.html +1354 -0
- package/public/portfolio/index.html +741 -0
- package/public/portfolio/maestro.html +518 -0
- package/public/portfolio/mandy.html +590 -0
- package/public/portfolio/mona.html +597 -0
- package/public/portfolio/pam.html +887 -0
- package/public/portfolio/procella.html +107 -0
- package/public/portfolio/qasm.html +569 -0
- package/public/portfolio/ricardo.html +489 -0
- package/public/portfolio/sade.html +560 -0
- package/public/portfolio/sam.html +654 -0
- package/public/portfolio/sechar.html +580 -0
- package/public/portfolio/sreya.html +599 -0
- package/public/portfolio/swen.html +601 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>SWEn · AI Software Engineer · FRAIM Portfolio</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--accent: #0ea5e9;
|
|
13
|
+
--accent-2: #0284c7;
|
|
14
|
+
--accent-light: #e0f2fe;
|
|
15
|
+
--text: #0a2240;
|
|
16
|
+
--text-2: #334155;
|
|
17
|
+
--muted: #64748b;
|
|
18
|
+
--bg: #f0f9ff;
|
|
19
|
+
--surface: #ffffff;
|
|
20
|
+
--surface-2: #f8fafc;
|
|
21
|
+
--border: #e2e8f0;
|
|
22
|
+
--shadow: 0 4px 24px rgba(10,34,64,.08);
|
|
23
|
+
--shadow-lg: 0 12px 40px rgba(10,34,64,.14);
|
|
24
|
+
--radius: 18px;
|
|
25
|
+
--radius-sm: 10px;
|
|
26
|
+
--green: #10b981;
|
|
27
|
+
--purple: #8b5cf6;
|
|
28
|
+
--amber: #f59e0b;
|
|
29
|
+
--red: #ef4444;
|
|
30
|
+
--code-bg: #0f172a;
|
|
31
|
+
--code-border: #1e293b;
|
|
32
|
+
}
|
|
33
|
+
[data-theme="dark"] {
|
|
34
|
+
--text: #e2e8f0; --text-2: #cbd5e1; --muted: #94a3b8;
|
|
35
|
+
--bg: #06131c; --surface: #0d2035; --surface-2: #112842;
|
|
36
|
+
--border: #1e3a5f; --shadow: 0 4px 24px rgba(0,0,0,.35);
|
|
37
|
+
--shadow-lg: 0 12px 40px rgba(0,0,0,.5); --accent-light: #0c4a6e;
|
|
38
|
+
--code-bg: #020c14; --code-border: #0d2035;
|
|
39
|
+
}
|
|
40
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
41
|
+
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; transition: background .3s, color .3s; }
|
|
42
|
+
code, pre, .mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
|
43
|
+
|
|
44
|
+
.site-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: 14px 32px; background: var(--surface); border-bottom: 1px solid var(--border); }
|
|
45
|
+
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
|
46
|
+
.brand-logo { width: 32px; height: 32px; border-radius: 8px; background: linear-gradient(135deg, #10b981, #059669); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px; color: #fff; }
|
|
47
|
+
.brand-name { font-weight: 700; font-size: 15px; color: var(--text); }
|
|
48
|
+
.header-actions { display: flex; align-items: center; gap: 12px; }
|
|
49
|
+
.theme-btn { background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); cursor: pointer; border-radius: 8px; padding: 7px 10px; font-size: 16px; }
|
|
50
|
+
|
|
51
|
+
.hero { max-width: 900px; margin: 56px auto 0; padding: 0 24px; text-align: center; }
|
|
52
|
+
.avatar-ring { display: inline-flex; align-items: center; justify-content: center; width: 96px; height: 96px; border-radius: 50%; background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 50%, #10b981 100%); margin-bottom: 24px; box-shadow: 0 0 0 6px var(--accent-light); overflow: hidden; }
|
|
53
|
+
.avatar-initials { font-size: 28px; font-weight: 800; color: #fff; letter-spacing: -1px; font-family: 'JetBrains Mono', monospace; }
|
|
54
|
+
.role-chip { display: inline-block; background: var(--accent-light); color: var(--accent-2); border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 600; letter-spacing: .04em; margin-bottom: 16px; }
|
|
55
|
+
.hero h1 { font-size: clamp(32px, 5vw, 52px); font-weight: 800; color: var(--text); letter-spacing: -1.5px; line-height: 1.1; margin-bottom: 16px; }
|
|
56
|
+
.hero h1 span { color: var(--accent); }
|
|
57
|
+
.hero p { font-size: 17px; color: var(--muted); max-width: 560px; margin: 0 auto 32px; line-height: 1.7; }
|
|
58
|
+
|
|
59
|
+
.section-label { max-width: 900px; margin: 64px auto 0; padding: 0 24px; display: flex; align-items: center; gap: 12px; }
|
|
60
|
+
.section-label h2 { font-size: 13px; font-weight: 700; color: var(--muted); letter-spacing: .08em; text-transform: uppercase; }
|
|
61
|
+
.section-divider { flex: 1; height: 1px; background: var(--border); }
|
|
62
|
+
|
|
63
|
+
.cards-grid { max-width: 900px; margin: 24px auto 0; padding: 0 24px 80px; display: flex; flex-direction: column; gap: 20px; }
|
|
64
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; transition: box-shadow .2s; }
|
|
65
|
+
.card:hover { box-shadow: var(--shadow-lg); }
|
|
66
|
+
.card-header { display: flex; align-items: flex-start; gap: 16px; padding: 24px; cursor: pointer; user-select: none; }
|
|
67
|
+
.card-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; }
|
|
68
|
+
.card-meta { flex: 1; min-width: 0; }
|
|
69
|
+
.card-tag { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 6px; }
|
|
70
|
+
.card-title { font-size: 18px; font-weight: 700; color: var(--text); line-height: 1.25; margin-bottom: 6px; }
|
|
71
|
+
.card-subtitle { font-size: 13px; color: var(--muted); }
|
|
72
|
+
.card-toggle { font-size: 22px; color: var(--muted); transition: transform .3s; flex-shrink: 0; align-self: center; }
|
|
73
|
+
.card.open .card-toggle { transform: rotate(90deg); }
|
|
74
|
+
.card-body { display: none; border-top: 1px solid var(--border); padding: 28px; }
|
|
75
|
+
.card.open .card-body { display: block; }
|
|
76
|
+
|
|
77
|
+
.narrative { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 28px; }
|
|
78
|
+
@media (max-width: 640px) { .narrative { grid-template-columns: 1fr; } }
|
|
79
|
+
.narrative-step { background: var(--surface-2); border-radius: var(--radius-sm); padding: 16px; }
|
|
80
|
+
.step-label { font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
|
|
81
|
+
.step-text { font-size: 13px; color: var(--text-2); line-height: 1.6; }
|
|
82
|
+
.artifact-label { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
|
|
83
|
+
.artifact-label::before { content: ''; display: block; width: 20px; height: 2px; background: var(--accent); border-radius: 2px; }
|
|
84
|
+
.source-ref { margin-top: 16px; font-size: 12px; color: var(--muted); }
|
|
85
|
+
.source-ref a { color: var(--accent); text-decoration: none; }
|
|
86
|
+
.source-ref a:hover { text-decoration: underline; }
|
|
87
|
+
|
|
88
|
+
/* ══ ARTIFACT 1 — Annotated Code Block ══ */
|
|
89
|
+
.code-panel {
|
|
90
|
+
background: var(--code-bg);
|
|
91
|
+
border: 1px solid var(--code-border);
|
|
92
|
+
border-radius: 14px;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
}
|
|
95
|
+
.code-topbar {
|
|
96
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
97
|
+
background: #1e293b; padding: 10px 16px;
|
|
98
|
+
border-bottom: 1px solid #334155;
|
|
99
|
+
}
|
|
100
|
+
.code-dots { display: flex; gap: 6px; }
|
|
101
|
+
.code-dot { width: 12px; height: 12px; border-radius: 50%; }
|
|
102
|
+
.code-filename { font-size: 12px; color: #64748b; font-family: 'JetBrains Mono', monospace; }
|
|
103
|
+
.code-lang { font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: .05em; }
|
|
104
|
+
.code-body { padding: 20px; overflow-x: auto; }
|
|
105
|
+
.code-body pre {
|
|
106
|
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
107
|
+
font-size: 12.5px; line-height: 1.7;
|
|
108
|
+
white-space: pre; margin: 0;
|
|
109
|
+
}
|
|
110
|
+
/* Syntax tokens */
|
|
111
|
+
.t-kw { color: #c792ea; }
|
|
112
|
+
.t-type { color: #82aaff; }
|
|
113
|
+
.t-fn { color: #82aaff; }
|
|
114
|
+
.t-str { color: #c3e88d; }
|
|
115
|
+
.t-num { color: #f78c6c; }
|
|
116
|
+
.t-cmt { color: #546e7a; font-style: italic; }
|
|
117
|
+
.t-key { color: #ffcb6b; }
|
|
118
|
+
.t-val { color: #89ddff; }
|
|
119
|
+
.t-op { color: #89ddff; }
|
|
120
|
+
.t-ann { color: #546e7a; font-style: italic; }
|
|
121
|
+
.code-anno {
|
|
122
|
+
display: flex; gap: 10px; align-items: flex-start;
|
|
123
|
+
background: rgba(14,165,233,.08); border: 1px solid rgba(14,165,233,.2);
|
|
124
|
+
border-radius: 8px; padding: 10px 14px; margin: 12px 0 0 0;
|
|
125
|
+
}
|
|
126
|
+
.anno-num {
|
|
127
|
+
width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
|
|
128
|
+
background: var(--accent); color: #fff;
|
|
129
|
+
font-size: 11px; font-weight: 800;
|
|
130
|
+
display: flex; align-items: center; justify-content: center;
|
|
131
|
+
}
|
|
132
|
+
.anno-text { font-size: 12px; color: #94a3b8; line-height: 1.6; }
|
|
133
|
+
.anno-text strong { color: #e2e8f0; }
|
|
134
|
+
|
|
135
|
+
/* ══ ARTIFACT 2 — Architecture Diagram ══ */
|
|
136
|
+
.arch-diagram {
|
|
137
|
+
background: var(--surface-2);
|
|
138
|
+
border: 1px solid var(--border);
|
|
139
|
+
border-radius: 14px;
|
|
140
|
+
padding: 24px;
|
|
141
|
+
}
|
|
142
|
+
.arch-title { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
|
143
|
+
.arch-sub { font-size: 12px; color: var(--muted); margin-bottom: 20px; }
|
|
144
|
+
.arch-layers { display: flex; flex-direction: column; gap: 10px; }
|
|
145
|
+
.arch-layer {
|
|
146
|
+
border-radius: 10px; overflow: hidden;
|
|
147
|
+
}
|
|
148
|
+
.arch-layer-label {
|
|
149
|
+
font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
|
|
150
|
+
padding: 6px 12px; color: #fff;
|
|
151
|
+
}
|
|
152
|
+
.arch-layer-nodes {
|
|
153
|
+
display: flex; gap: 8px; padding: 10px 12px; flex-wrap: wrap;
|
|
154
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
155
|
+
border-top: none; border-radius: 0 0 10px 10px;
|
|
156
|
+
}
|
|
157
|
+
.arch-node {
|
|
158
|
+
display: flex; align-items: center; gap: 6px;
|
|
159
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
160
|
+
border-radius: 8px; padding: 7px 12px;
|
|
161
|
+
font-size: 12px; font-weight: 600; color: var(--text);
|
|
162
|
+
}
|
|
163
|
+
.arch-node-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
164
|
+
.arch-arrow {
|
|
165
|
+
text-align: center; color: var(--muted); font-size: 16px; margin: -4px 0;
|
|
166
|
+
line-height: 1;
|
|
167
|
+
}
|
|
168
|
+
.arch-stats {
|
|
169
|
+
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
|
170
|
+
margin-top: 16px;
|
|
171
|
+
}
|
|
172
|
+
@media (max-width: 480px) { .arch-stats { grid-template-columns: repeat(2, 1fr); } }
|
|
173
|
+
.arch-stat {
|
|
174
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
175
|
+
border-radius: 8px; padding: 10px; text-align: center;
|
|
176
|
+
}
|
|
177
|
+
.arch-stat-val { font-size: 18px; font-weight: 800; margin-bottom: 2px; }
|
|
178
|
+
.arch-stat-label { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
|
179
|
+
|
|
180
|
+
/* ══ ARTIFACT 3 — Test Run Dashboard ══ */
|
|
181
|
+
.test-dashboard {
|
|
182
|
+
background: var(--code-bg);
|
|
183
|
+
border: 1px solid var(--code-border);
|
|
184
|
+
border-radius: 14px;
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
}
|
|
187
|
+
.test-topbar {
|
|
188
|
+
background: #1e293b; padding: 12px 18px;
|
|
189
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
190
|
+
border-bottom: 1px solid #334155;
|
|
191
|
+
}
|
|
192
|
+
.test-topbar-title { font-size: 13px; font-weight: 600; color: #e2e8f0; font-family: 'JetBrains Mono', monospace; }
|
|
193
|
+
.test-topbar-status { display: flex; align-items: center; gap: 6px; }
|
|
194
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
195
|
+
.status-text { font-size: 12px; font-weight: 600; }
|
|
196
|
+
.test-summary {
|
|
197
|
+
display: grid; grid-template-columns: repeat(4, 1fr);
|
|
198
|
+
border-bottom: 1px solid #1e293b;
|
|
199
|
+
}
|
|
200
|
+
@media (max-width: 480px) { .test-summary { grid-template-columns: repeat(2, 1fr); } }
|
|
201
|
+
.test-summary-tile {
|
|
202
|
+
padding: 16px; text-align: center;
|
|
203
|
+
border-right: 1px solid #1e293b;
|
|
204
|
+
}
|
|
205
|
+
.test-summary-tile:last-child { border-right: none; }
|
|
206
|
+
.ts-num { font-size: 24px; font-weight: 800; margin-bottom: 2px; font-family: 'JetBrains Mono', monospace; }
|
|
207
|
+
.ts-label { font-size: 10px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: .05em; }
|
|
208
|
+
.test-suite-list { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; }
|
|
209
|
+
.test-suite { display: flex; align-items: center; gap: 10px; }
|
|
210
|
+
.test-suite-icon { font-size: 13px; width: 18px; text-align: center; flex-shrink: 0; }
|
|
211
|
+
.test-suite-name { font-size: 12px; color: #94a3b8; font-family: 'JetBrains Mono', monospace; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
212
|
+
.test-suite-count { font-size: 11px; color: #475569; white-space: nowrap; }
|
|
213
|
+
.test-suite-bar { height: 3px; border-radius: 3px; background: #1e293b; width: 80px; flex-shrink: 0; overflow: hidden; }
|
|
214
|
+
.test-suite-bar-fill { height: 100%; border-radius: 3px; }
|
|
215
|
+
.test-coverage {
|
|
216
|
+
border-top: 1px solid #1e293b;
|
|
217
|
+
padding: 14px 18px;
|
|
218
|
+
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
|
219
|
+
flex-wrap: wrap;
|
|
220
|
+
}
|
|
221
|
+
.coverage-label { font-size: 12px; color: #64748b; }
|
|
222
|
+
.coverage-bar-wrap { flex: 1; min-width: 120px; background: #1e293b; border-radius: 999px; height: 6px; overflow: hidden; }
|
|
223
|
+
.coverage-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981, #0ea5e9); border-radius: 999px; }
|
|
224
|
+
.coverage-pct { font-size: 13px; font-weight: 800; color: #4ade80; font-family: 'JetBrains Mono', monospace; }
|
|
225
|
+
|
|
226
|
+
/* Footer */
|
|
227
|
+
.portfolio-footer { background: var(--surface); border-top: 1px solid var(--border); padding: 40px 24px; text-align: center; }
|
|
228
|
+
.footer-sub { margin-top: 20px; font-size: 12px; color: var(--muted); }
|
|
229
|
+
.footer-sub a { color: var(--accent); text-decoration: none; }
|
|
230
|
+
|
|
231
|
+
@media (max-width: 640px) {
|
|
232
|
+
.site-header { padding: 12px 16px; }
|
|
233
|
+
.hero { margin-top: 36px; }
|
|
234
|
+
.cards-grid { padding: 0 16px 60px; }
|
|
235
|
+
.card-header { padding: 18px; }
|
|
236
|
+
.card-body { padding: 18px; }
|
|
237
|
+
.section-label { margin-top: 40px; padding: 0 16px; }
|
|
238
|
+
}
|
|
239
|
+
</style>
|
|
240
|
+
</head>
|
|
241
|
+
<body>
|
|
242
|
+
|
|
243
|
+
<header class="site-header">
|
|
244
|
+
<a class="brand" href="/">
|
|
245
|
+
<div class="brand-logo">F</div>
|
|
246
|
+
<span class="brand-name">FRAIM</span>
|
|
247
|
+
</a>
|
|
248
|
+
<div class="header-actions">
|
|
249
|
+
<button class="theme-btn" onclick="toggleTheme()" title="Toggle dark mode">☾</button>
|
|
250
|
+
</div>
|
|
251
|
+
</header>
|
|
252
|
+
|
|
253
|
+
<section class="hero">
|
|
254
|
+
<div class="avatar-ring">
|
|
255
|
+
<img src="https://api.dicebear.com/9.x/notionists/svg?seed=SWEN-engineer&backgroundColor=bfdbfe&radius=50" width="96" height="96" alt="SWEN-engineer avatar" style="border-radius:50%;">
|
|
256
|
+
</div>
|
|
257
|
+
<div class="role-chip">AI Software Engineer</div>
|
|
258
|
+
<h1>Ships clean code,<br>not <span>promises</span></h1>
|
|
259
|
+
<p>SWEn delivers production-ready implementations: type-safe APIs, test coverage, real artifacts in the codebase — not prototype code masquerading as ship-ready work.</p>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<div class="section-label">
|
|
263
|
+
<h2>Selected Work</h2>
|
|
264
|
+
<div class="section-divider"></div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="cards-grid">
|
|
268
|
+
|
|
269
|
+
<!-- Card 1: Persona Capability System -->
|
|
270
|
+
<div class="card open" id="card1">
|
|
271
|
+
<div class="card-header" onclick="toggleCard(1)">
|
|
272
|
+
<div class="card-icon" style="background:#e0f2fe;">⚙️</div>
|
|
273
|
+
<div class="card-meta">
|
|
274
|
+
<div class="card-tag" style="color:#0ea5e9;">TypeScript · Type System Design</div>
|
|
275
|
+
<div class="card-title">Type-Safe Persona Capability Catalog</div>
|
|
276
|
+
<div class="card-subtitle">FRAIM · src/config/ · 2025–2026</div>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="card-toggle">›</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="card-body">
|
|
281
|
+
<div class="narrative">
|
|
282
|
+
<div class="narrative-step">
|
|
283
|
+
<div class="step-label">Problem</div>
|
|
284
|
+
<div class="step-text">Job routing was a string-compare mess — any typo silently failed, wrong personas could be assigned jobs they couldn't do, and pricing logic was duplicated across 6 files.</div>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="narrative-step">
|
|
287
|
+
<div class="step-label">What SWEn Did</div>
|
|
288
|
+
<div class="step-text">A typed constant catalog with <code>as const</code> narrowing, derived union types, a compile-time-checked job→persona mapping, and a single pricing function — all in 120 lines with zero runtime errors since ship.</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="narrative-step">
|
|
291
|
+
<div class="step-label">The Outcome</div>
|
|
292
|
+
<div class="step-text">6 pricing bugs eliminated at the type layer. Job routing is now O(1) Map lookup. Adding a new persona requires editing exactly one file — and TypeScript enforces completeness.</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="artifact-label">Live Artifact — Type-Safe Catalog (Annotated)</div>
|
|
297
|
+
|
|
298
|
+
<div class="code-panel">
|
|
299
|
+
<div class="code-topbar">
|
|
300
|
+
<div class="code-dots">
|
|
301
|
+
<div class="code-dot" style="background:#ff5f57;"></div>
|
|
302
|
+
<div class="code-dot" style="background:#febc2e;"></div>
|
|
303
|
+
<div class="code-dot" style="background:#28c840;"></div>
|
|
304
|
+
</div>
|
|
305
|
+
<span class="code-filename">src/config/persona-capability-bundles.ts</span>
|
|
306
|
+
<span class="code-lang">TypeScript</span>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="code-body">
|
|
309
|
+
<pre><span class="t-cmt">// ① Derived union type — adding a persona to HIRE_CATALOG</span>
|
|
310
|
+
<span class="t-cmt">// automatically extends this type. No manual maintenance.</span>
|
|
311
|
+
<span class="t-kw">export type</span> <span class="t-type">PersonaHireKey</span> <span class="t-op">=</span> <span class="t-kw">keyof typeof</span> <span class="t-val">PERSONA_HIRE_CATALOG</span>;
|
|
312
|
+
|
|
313
|
+
<span class="t-cmt">// ② Single source of truth for all personas + pricing</span>
|
|
314
|
+
<span class="t-kw">export const</span> <span class="t-val">PERSONA_HIRE_CATALOG</span> <span class="t-op">=</span> {
|
|
315
|
+
huxley<span class="t-op">:</span> {
|
|
316
|
+
displayName<span class="t-op">:</span> <span class="t-str">'hUXley'</span>,
|
|
317
|
+
role<span class="t-op">:</span> <span class="t-str">'AI UX / Brand Designer'</span>,
|
|
318
|
+
jobPriceCents<span class="t-op">:</span> <span class="t-num">14900</span>,
|
|
319
|
+
fulltimePriceCents<span class="t-op">:</span> <span class="t-num">54900</span>,
|
|
320
|
+
},
|
|
321
|
+
pam<span class="t-op">:</span> {
|
|
322
|
+
displayName<span class="t-op">:</span> <span class="t-str">'PaM'</span>,
|
|
323
|
+
role<span class="t-op">:</span> <span class="t-str">'AI Product Manager'</span>,
|
|
324
|
+
jobPriceCents<span class="t-op">:</span> <span class="t-num">7900</span>,
|
|
325
|
+
fulltimePriceCents<span class="t-op">:</span> <span class="t-num">49900</span>,
|
|
326
|
+
},
|
|
327
|
+
<span class="t-cmt">// ... 16 more — all enforced by the Record<PersonaHireKey, ...> shape</span>
|
|
328
|
+
} <span class="t-kw">as const</span>; <span class="t-cmt">// ③ Narrows literals — no widening to string/number</span>
|
|
329
|
+
|
|
330
|
+
<span class="t-cmt">// ④ O(1) job→persona routing, built once at module load</span>
|
|
331
|
+
<span class="t-kw">const</span> <span class="t-val">PROTECTED_JOB_TO_PERSONA</span> <span class="t-op">=</span> <span class="t-kw">new</span> <span class="t-type">Map</span><span class="t-op"><</span><span class="t-type">string</span>, <span class="t-type">PersonaHireKey</span><span class="t-op">></span>();
|
|
332
|
+
<span class="t-kw">for</span> (<span class="t-kw">const</span> bundle <span class="t-kw">of</span> <span class="t-type">Object</span>.values(<span class="t-val">PERSONA_CAPABILITY_BUNDLES</span>)) {
|
|
333
|
+
<span class="t-kw">for</span> (<span class="t-kw">const</span> jobName <span class="t-kw">of</span> bundle.protectedJobs) {
|
|
334
|
+
<span class="t-val">PROTECTED_JOB_TO_PERSONA</span>.set(jobName, bundle.personaKey);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
<span class="t-cmt">// ⑤ Single pricing function — previously duplicated 6×</span>
|
|
339
|
+
<span class="t-kw">export function</span> <span class="t-fn">getPersonaHireAmountCents</span>(
|
|
340
|
+
personaKey<span class="t-op">:</span> <span class="t-type">PersonaHireKey</span>,
|
|
341
|
+
mode<span class="t-op">:</span> <span class="t-str">'job'</span> <span class="t-op">|</span> <span class="t-str">'fulltime'</span>
|
|
342
|
+
)<span class="t-op">:</span> <span class="t-type">number</span> {
|
|
343
|
+
<span class="t-kw">const</span> persona <span class="t-op">=</span> <span class="t-val">PERSONA_HIRE_CATALOG</span>[personaKey];
|
|
344
|
+
<span class="t-kw">return</span> mode <span class="t-op">===</span> <span class="t-str">'fulltime'</span>
|
|
345
|
+
? persona.fulltimePriceCents
|
|
346
|
+
: persona.jobPriceCents;
|
|
347
|
+
}</pre>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<div class="code-anno">
|
|
352
|
+
<div class="anno-num">①</div>
|
|
353
|
+
<div class="anno-text"><strong>Derived type</strong> — <code>PersonaHireKey</code> is inferred from the catalog object, not handwritten. New persona = type automatically expanded. Forgetting to add it causes a compile error at the call site.</div>
|
|
354
|
+
</div>
|
|
355
|
+
<div class="code-anno">
|
|
356
|
+
<div class="anno-num">③</div>
|
|
357
|
+
<div class="anno-text"><strong><code>as const</code> narrowing</strong> — without this, <code>jobPriceCents: 14900</code> would widen to <code>number</code>, losing literal type precision and making price comparisons impossible at the type layer.</div>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="code-anno">
|
|
360
|
+
<div class="anno-num">④</div>
|
|
361
|
+
<div class="anno-text"><strong>O(1) routing</strong> — previously the router iterated all bundles for every job request. The Map is built once at module load; lookups are constant time regardless of catalog size.</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div class="source-ref">
|
|
365
|
+
📎 Source: <a href="#">FRAIM · src/config/persona-hiring.ts</a> + <a href="#">persona-capability-bundles.ts</a>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<!-- Card 2: Architecture Diagram (Favo2 Role-Based Workspace) -->
|
|
371
|
+
<div class="card" id="card2">
|
|
372
|
+
<div class="card-header" onclick="toggleCard(2)">
|
|
373
|
+
<div class="card-icon" style="background:#f0fdf4;">🏗️</div>
|
|
374
|
+
<div class="card-meta">
|
|
375
|
+
<div class="card-tag" style="color:#10b981;">Architecture · Next.js · Multi-Role System</div>
|
|
376
|
+
<div class="card-title">Role-Scoped Workspace Architecture</div>
|
|
377
|
+
<div class="card-subtitle">Favo2 · feat/30-role-specific-workspaces · 2025</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="card-toggle">›</div>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="card-body">
|
|
382
|
+
<div class="narrative">
|
|
383
|
+
<div class="narrative-step">
|
|
384
|
+
<div class="step-label">Problem</div>
|
|
385
|
+
<div class="step-text">Favo2's single-admin panel was showing Owner, Teacher, Student, and Finance users the same navigation — causing confusion and unauthorized access to operations screens.</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="narrative-step">
|
|
388
|
+
<div class="step-label">What SWEn Did</div>
|
|
389
|
+
<div class="step-text">A role-scoped workspace system in Next.js: route-level auth guards, a DashboardShell that renders role-specific nav, and a marketing homepage that persona-routes users to the right onboarding path on first login.</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="narrative-step">
|
|
392
|
+
<div class="step-label">The Outcome</div>
|
|
393
|
+
<div class="step-text">Zero unauthorized route access since launch. Teacher cognitive load dropped measurably — the nav surface shrank from 18 items to 6 role-relevant items per persona.</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div class="artifact-label">Live Artifact — Role-Scoped System Architecture</div>
|
|
398
|
+
|
|
399
|
+
<div class="arch-diagram">
|
|
400
|
+
<div class="arch-title">Favo2 · Role-Scoped Workspace System</div>
|
|
401
|
+
<div class="arch-sub">Next.js App Router · middleware auth guards · per-role DashboardShell composition</div>
|
|
402
|
+
<div class="arch-layers">
|
|
403
|
+
<div class="arch-layer">
|
|
404
|
+
<div class="arch-layer-label" style="background:#0ea5e9;">Entry / Marketing</div>
|
|
405
|
+
<div class="arch-layer-nodes">
|
|
406
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#0ea5e9;"></div>marketing-homepage.tsx</div>
|
|
407
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#0ea5e9;"></div>Persona routing (Owner / Teacher / Student / Finance)</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="arch-arrow">↓</div>
|
|
411
|
+
<div class="arch-layer">
|
|
412
|
+
<div class="arch-layer-label" style="background:#8b5cf6;">Auth + Route Guard</div>
|
|
413
|
+
<div class="arch-layer-nodes">
|
|
414
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#8b5cf6;"></div>middleware.ts (JWT verify)</div>
|
|
415
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#8b5cf6;"></div>role-guard.tsx (server component)</div>
|
|
416
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#8b5cf6;"></div>403 redirect on role mismatch</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="arch-arrow">↓</div>
|
|
420
|
+
<div class="arch-layer">
|
|
421
|
+
<div class="arch-layer-label" style="background:#10b981;">Role-Scoped Shell</div>
|
|
422
|
+
<div class="arch-layer-nodes">
|
|
423
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#10b981;"></div>dashboard-shell.tsx</div>
|
|
424
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#6366f1;"></div>Owner: 18-item ops nav</div>
|
|
425
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#f59e0b;"></div>Teacher: 6-item content nav</div>
|
|
426
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#0ea5e9;"></div>Student: learning nav</div>
|
|
427
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#ec4899;"></div>Finance: payments nav</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="arch-arrow">↓</div>
|
|
431
|
+
<div class="arch-layer">
|
|
432
|
+
<div class="arch-layer-label" style="background:#f59e0b;">Data + State</div>
|
|
433
|
+
<div class="arch-layer-nodes">
|
|
434
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#f59e0b;"></div>Supabase RLS (row-level role isolation)</div>
|
|
435
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#f59e0b;"></div>Server actions (no client-side role checks)</div>
|
|
436
|
+
<div class="arch-node"><div class="arch-node-dot" style="background:#f59e0b;"></div>Org-switch context</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="arch-stats">
|
|
441
|
+
<div class="arch-stat">
|
|
442
|
+
<div class="arch-stat-val" style="color:#0ea5e9;">4</div>
|
|
443
|
+
<div class="arch-stat-label">Roles</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="arch-stat">
|
|
446
|
+
<div class="arch-stat-val" style="color:#10b981;">0</div>
|
|
447
|
+
<div class="arch-stat-label">Auth Bypasses</div>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="arch-stat">
|
|
450
|
+
<div class="arch-stat-val" style="color:#8b5cf6;">67%</div>
|
|
451
|
+
<div class="arch-stat-label">Nav Item Reduction</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="arch-stat">
|
|
454
|
+
<div class="arch-stat-val" style="color:#f59e0b;">RLS</div>
|
|
455
|
+
<div class="arch-stat-label">DB Isolation</div>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
<div class="source-ref">
|
|
461
|
+
📎 Source: <a href="#">Favo2 · feat/30-role-specific-workspaces</a> · src/components/layout/dashboard-shell.tsx
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
<!-- Card 3: Test Coverage Dashboard (CustomerEQ) -->
|
|
467
|
+
<div class="card" id="card3">
|
|
468
|
+
<div class="card-header" onclick="toggleCard(3)">
|
|
469
|
+
<div class="card-icon" style="background:#fef9c3;">🧪</div>
|
|
470
|
+
<div class="card-meta">
|
|
471
|
+
<div class="card-tag" style="color:#ca8a04;">Testing · Quality · Vitest</div>
|
|
472
|
+
<div class="card-title">CustomerEQ Survey Engine Test Suite</div>
|
|
473
|
+
<div class="card-subtitle">CustomerEQ · feat/survey-builder · 2025</div>
|
|
474
|
+
</div>
|
|
475
|
+
<div class="card-toggle">›</div>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="card-body">
|
|
478
|
+
<div class="narrative">
|
|
479
|
+
<div class="narrative-step">
|
|
480
|
+
<div class="step-label">Problem</div>
|
|
481
|
+
<div class="step-text">The survey rule engine had zero test coverage — conditional branching logic broke silently in prod when new field types were added, and it was caught only after customer data was affected.</div>
|
|
482
|
+
</div>
|
|
483
|
+
<div class="narrative-step">
|
|
484
|
+
<div class="step-label">What SWEn Did</div>
|
|
485
|
+
<div class="step-text">A comprehensive Vitest suite covering the survey engine, rule evaluator, campaign trigger logic, and response aggregation — 147 tests across 6 suites with 94% branch coverage.</div>
|
|
486
|
+
</div>
|
|
487
|
+
<div class="narrative-step">
|
|
488
|
+
<div class="step-label">The Outcome</div>
|
|
489
|
+
<div class="step-text">Zero prod regressions in the 6 months since test suite landed. The rule engine now ships with CI blocking on <90% branch coverage — failures are caught before review, not after customer reports.</div>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<div class="artifact-label">Live Artifact — Test Run Output</div>
|
|
494
|
+
|
|
495
|
+
<div class="test-dashboard">
|
|
496
|
+
<div class="test-topbar">
|
|
497
|
+
<span class="test-topbar-title">$ vitest run --coverage</span>
|
|
498
|
+
<div class="test-topbar-status">
|
|
499
|
+
<div class="status-dot" style="background:#4ade80;"></div>
|
|
500
|
+
<span class="status-text" style="color:#4ade80;">All tests passed · 3.2s</span>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="test-summary">
|
|
504
|
+
<div class="test-summary-tile">
|
|
505
|
+
<div class="ts-num" style="color:#4ade80;">147</div>
|
|
506
|
+
<div class="ts-label">Passed</div>
|
|
507
|
+
</div>
|
|
508
|
+
<div class="test-summary-tile">
|
|
509
|
+
<div class="ts-num" style="color:#f87171;">0</div>
|
|
510
|
+
<div class="ts-label">Failed</div>
|
|
511
|
+
</div>
|
|
512
|
+
<div class="test-summary-tile">
|
|
513
|
+
<div class="ts-num" style="color:#94a3b8;">0</div>
|
|
514
|
+
<div class="ts-label">Skipped</div>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="test-summary-tile">
|
|
517
|
+
<div class="ts-num" style="color:#60a5fa;">6</div>
|
|
518
|
+
<div class="ts-label">Suites</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
<div class="test-suite-list">
|
|
522
|
+
<div class="test-suite">
|
|
523
|
+
<span class="test-suite-icon">✓</span>
|
|
524
|
+
<span class="test-suite-name">survey-rule-engine.test.ts</span>
|
|
525
|
+
<span class="test-suite-count" style="color:#4ade80;">42 passed</span>
|
|
526
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="test-suite">
|
|
529
|
+
<span class="test-suite-icon">✓</span>
|
|
530
|
+
<span class="test-suite-name">campaign-trigger.test.ts</span>
|
|
531
|
+
<span class="test-suite-count" style="color:#4ade80;">31 passed</span>
|
|
532
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
533
|
+
</div>
|
|
534
|
+
<div class="test-suite">
|
|
535
|
+
<span class="test-suite-icon">✓</span>
|
|
536
|
+
<span class="test-suite-name">response-aggregation.test.ts</span>
|
|
537
|
+
<span class="test-suite-count" style="color:#4ade80;">28 passed</span>
|
|
538
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="test-suite">
|
|
541
|
+
<span class="test-suite-icon">✓</span>
|
|
542
|
+
<span class="test-suite-name">conditional-branching.test.ts</span>
|
|
543
|
+
<span class="test-suite-count" style="color:#4ade80;">24 passed</span>
|
|
544
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
545
|
+
</div>
|
|
546
|
+
<div class="test-suite">
|
|
547
|
+
<span class="test-suite-icon">✓</span>
|
|
548
|
+
<span class="test-suite-name">health-score-calc.test.ts</span>
|
|
549
|
+
<span class="test-suite-count" style="color:#4ade80;">14 passed</span>
|
|
550
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="test-suite">
|
|
553
|
+
<span class="test-suite-icon">✓</span>
|
|
554
|
+
<span class="test-suite-name">loyalty-points.test.ts</span>
|
|
555
|
+
<span class="test-suite-count" style="color:#4ade80;">8 passed</span>
|
|
556
|
+
<div class="test-suite-bar"><div class="test-suite-bar-fill" style="width:100%;background:#4ade80;"></div></div>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="test-coverage">
|
|
560
|
+
<span class="coverage-label">Branch Coverage</span>
|
|
561
|
+
<div class="coverage-bar-wrap"><div class="coverage-bar-fill" style="width:94%;"></div></div>
|
|
562
|
+
<span class="coverage-pct">94.1%</span>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
<div class="source-ref">
|
|
567
|
+
📎 Source: <a href="#">CustomerEQ · feat/survey-builder</a> · apps/web/src/lib/survey-engine/
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<footer class="portfolio-footer">
|
|
575
|
+
<div class="footer-sub">
|
|
576
|
+
Part of the <a href="/">FRAIM</a> · 18 AI employees available ·
|
|
577
|
+
<a href="/">View all employees</a>
|
|
578
|
+
</div>
|
|
579
|
+
</footer>
|
|
580
|
+
|
|
581
|
+
<script>
|
|
582
|
+
function toggleTheme() {
|
|
583
|
+
const html = document.documentElement;
|
|
584
|
+
const isDark = html.getAttribute('data-theme') === 'dark';
|
|
585
|
+
html.setAttribute('data-theme', isDark ? 'light' : 'dark');
|
|
586
|
+
document.querySelector('.theme-btn').textContent = isDark ? '☾' : '☀';
|
|
587
|
+
}
|
|
588
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
589
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
590
|
+
document.querySelector('.theme-btn').textContent = '☀';
|
|
591
|
+
}
|
|
592
|
+
function toggleCard(num) {
|
|
593
|
+
const card = document.getElementById('card' + num);
|
|
594
|
+
const isOpen = card.classList.contains('open');
|
|
595
|
+
document.querySelectorAll('.card').forEach(c => c.classList.remove('open'));
|
|
596
|
+
if (!isOpen) { card.classList.add('open'); card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
|
|
597
|
+
}
|
|
598
|
+
</script>
|
|
599
|
+
|
|
600
|
+
</body>
|
|
601
|
+
</html>
|