Portfolio v1: Playwright runner UI
This commit is contained in:
commit
8894748179
6
assets/favicon.svg
Normal file
6
assets/favicon.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" fill="#1e1e1e"/>
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#4ec9b0" stroke-width="1.5"/>
|
||||||
|
<path d="M13 10.5 L23 16 L13 21.5 Z" fill="#4ec9b0"/>
|
||||||
|
<circle cx="9" cy="16" r="1.4" fill="#9da0a4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
455
css/app.css
Normal file
455
css/app.css
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/* app.css — Playwright runner UI */
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 36px 1fr 22px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== TOP BAR ===================== */
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--topbar-bg);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.topbar__left, .topbar__right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.topbar__right { justify-content: flex-end; }
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); }
|
||||||
|
.brand__logo { width: 22px; height: 22px; color: var(--text-2); }
|
||||||
|
.brand__name { font-family: var(--font-mono); letter-spacing: .2px; }
|
||||||
|
.crumb { color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
|
.crumb__sep { color: var(--text-4); padding: 0 6px; }
|
||||||
|
.crumb strong { color: var(--text); font-weight: 500; }
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
height: 24px; padding: 0 12px;
|
||||||
|
background: var(--bg-3); border: 1px solid var(--line);
|
||||||
|
border-radius: 999px; font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-4); display: inline-block; }
|
||||||
|
.dot--idle { background: var(--text-4); }
|
||||||
|
.dot--running { background: var(--accent); box-shadow: 0 0 0 0 rgba(78,201,176,.6); animation: pulse 1.2s infinite; }
|
||||||
|
.dot--pass { background: var(--pass); }
|
||||||
|
.dot--fail { background: var(--fail); }
|
||||||
|
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,201,176,.55);} 70%{box-shadow:0 0 0 8px rgba(78,201,176,0);} 100%{box-shadow:0 0 0 0 rgba(78,201,176,0);} }
|
||||||
|
|
||||||
|
.status-counts { display: inline-flex; gap: 10px; padding-left: 10px; border-left: 1px solid var(--line-2); }
|
||||||
|
.cnt em { font-style: normal; font-weight: 600; }
|
||||||
|
.cnt--pass { color: var(--pass); }
|
||||||
|
.cnt--fail { color: var(--fail); }
|
||||||
|
.cnt--skip { color: var(--skip); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 24px; padding: 0 10px;
|
||||||
|
background: var(--bg-3); color: var(--text);
|
||||||
|
border: 1px solid var(--line-2); border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer; transition: background .15s var(--ease), border-color .15s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--hover); }
|
||||||
|
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.btn--run {
|
||||||
|
background: var(--pass); color: #0b1f1a; border-color: transparent; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn--run:hover { background: var(--pass-2); }
|
||||||
|
.btn--ghost { background: transparent; border-color: transparent; color: var(--text-2); }
|
||||||
|
.btn--ghost:hover { background: var(--hover); color: var(--text); }
|
||||||
|
.iconbtn { background: transparent; border: 0; color: var(--text-3); cursor: pointer; padding: 4px; border-radius: 3px; }
|
||||||
|
.iconbtn:hover { background: var(--hover); color: var(--text); }
|
||||||
|
|
||||||
|
.toggle { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); color: var(--text-3); font-size: var(--text-xs); user-select: none; cursor: pointer; }
|
||||||
|
.toggle input { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ===================== LAYOUT ===================== */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== SIDEBAR ===================== */
|
||||||
|
.sidebar {
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.sidebar__head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
}
|
||||||
|
.sidebar__title {
|
||||||
|
font-size: 10.5px; letter-spacing: .14em; color: var(--text-3); text-transform: uppercase; font-weight: 600;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin: 4px 10px 8px;
|
||||||
|
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||||
|
border-radius: 3px; padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.filter:focus-within { border-color: var(--accent); }
|
||||||
|
.filter__icon { color: var(--text-4); font-size: 13px; }
|
||||||
|
.filter input {
|
||||||
|
flex: 1; background: transparent; border: 0; outline: 0;
|
||||||
|
color: var(--text); font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
background: var(--tag-bg); color: var(--tag-fg);
|
||||||
|
padding: 2px 6px; border-radius: 3px; cursor: pointer; user-select: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.tag:hover { background: var(--hover); }
|
||||||
|
.tag.is-active { background: var(--accent); color: #0b1f1a; }
|
||||||
|
:root[data-theme='light'] .tag.is-active { color: #fff; }
|
||||||
|
|
||||||
|
.tree { flex: 1; overflow: auto; padding: 4px 0 16px; font-size: var(--text-sm); }
|
||||||
|
.suite { padding: 6px 10px 2px; }
|
||||||
|
.suite__head { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
|
.suite__caret { width: 12px; display: inline-block; transition: transform .15s; }
|
||||||
|
.suite.collapsed .suite__caret { transform: rotate(-90deg); }
|
||||||
|
.suite.collapsed .test { display: none; }
|
||||||
|
.suite__name { color: var(--text-2); }
|
||||||
|
.suite__name em { color: var(--accent); font-style: normal; }
|
||||||
|
|
||||||
|
.test {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 14px 1fr auto;
|
||||||
|
gap: 6px; align-items: center;
|
||||||
|
padding: 4px 10px 4px 22px;
|
||||||
|
cursor: pointer; border-left: 2px solid transparent;
|
||||||
|
transition: background .1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.test:hover { background: var(--hover); }
|
||||||
|
.test.is-selected { background: var(--active); border-left-color: var(--accent); }
|
||||||
|
.test.is-running { background: var(--hover); }
|
||||||
|
.test__run {
|
||||||
|
width: 18px; height: 18px; border-radius: 3px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--green-arrow); background: transparent;
|
||||||
|
border: 0; cursor: pointer; opacity: .85;
|
||||||
|
}
|
||||||
|
.test__run:hover { background: var(--hover); opacity: 1; }
|
||||||
|
.test__run svg { width: 10px; height: 10px; }
|
||||||
|
.test.is-running .test__run, .test.is-passed .test__run { opacity: 1; }
|
||||||
|
|
||||||
|
.test__icon { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.test__icon svg { width: 12px; height: 12px; }
|
||||||
|
.icon--idle { color: var(--text-4); }
|
||||||
|
.icon--run { color: var(--accent); }
|
||||||
|
.icon--pass { color: var(--pass); }
|
||||||
|
.icon--fail { color: var(--fail); }
|
||||||
|
|
||||||
|
.spin { animation: spin 1s linear infinite; transform-origin: center; }
|
||||||
|
@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
|
||||||
|
|
||||||
|
.test__title { color: var(--text); font-family: var(--font-mono); font-size: var(--text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.test__title span.kw { color: var(--info); }
|
||||||
|
.test__title span.str { color: #ce9178; }
|
||||||
|
:root[data-theme='light'] .test__title span.str { color: #a31515; }
|
||||||
|
|
||||||
|
.test__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
|
||||||
|
|
||||||
|
.test__tags { grid-column: 3 / span 2; padding-top: 2px; display: flex; flex-wrap: wrap; gap: 3px; }
|
||||||
|
.test__tags .tag { font-size: 10px; padding: 1px 5px; }
|
||||||
|
|
||||||
|
.sidebar__foot {
|
||||||
|
padding: 8px 12px; border-top: 1px solid var(--line);
|
||||||
|
color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== MAIN PANE ===================== */
|
||||||
|
.main { display: grid; grid-template-rows: 34px 1fr; min-height: 0; background: var(--bg); }
|
||||||
|
.tabs {
|
||||||
|
display: flex; align-items: stretch;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: transparent; border: 0; cursor: pointer;
|
||||||
|
padding: 0 14px; color: var(--text-3); font-size: var(--text-xs); font-family: var(--font-mono);
|
||||||
|
border-top: 2px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.is-active { color: var(--text); background: var(--bg); border-top-color: var(--accent); }
|
||||||
|
.tabs__spacer { flex: 1; }
|
||||||
|
.tabs__meta { display: flex; align-items: center; padding: 0 14px; color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
|
||||||
|
|
||||||
|
.pane { display: none; height: 100%; overflow: auto; padding: 0; }
|
||||||
|
.pane.is-active { display: block; }
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.hero { padding: 36px 36px 18px; border-bottom: 1px solid var(--line); }
|
||||||
|
.hero__tag { display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; color: var(--accent); background: rgba(78,201,176,.1); padding: 2px 8px; border-radius: 3px; text-transform: uppercase; margin-bottom: 12px; }
|
||||||
|
.hero__title { margin: 0; font-size: clamp(34px, 4vw, 52px); font-weight: 700; letter-spacing: -.02em; line-height: 1.05; }
|
||||||
|
.hero__sub { color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-sm); margin: 8px 0 0; }
|
||||||
|
.hero__hint { color: var(--text-3); margin: 18px 0 0; font-size: var(--text-sm); }
|
||||||
|
.kbd { font-family: var(--font-mono); background: var(--kbd-bg); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); }
|
||||||
|
|
||||||
|
/* RESULTS list */
|
||||||
|
.results { padding: 12px 0 80px; }
|
||||||
|
|
||||||
|
.result {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 36px;
|
||||||
|
}
|
||||||
|
.result__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px 18px 1fr auto auto;
|
||||||
|
gap: 10px; align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.result__caret { color: var(--text-4); transition: transform .2s; }
|
||||||
|
.result.is-open .result__caret { transform: rotate(90deg); }
|
||||||
|
.result__status { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.result__status svg { width: 14px; height: 14px; }
|
||||||
|
.result__title { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text); }
|
||||||
|
.result__title .kw { color: var(--info); }
|
||||||
|
.result__title .str { color: #ce9178; }
|
||||||
|
:root[data-theme='light'] .result__title .str { color: #a31515; }
|
||||||
|
.result__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 11px; }
|
||||||
|
.result__rerun { color: var(--text-3); background: transparent; border: 0; cursor: pointer; padding: 4px; border-radius: 3px; }
|
||||||
|
.result__rerun:hover { background: var(--hover); color: var(--text); }
|
||||||
|
|
||||||
|
.result__body { display: none; padding: 4px 0 24px 46px; animation: slideDown .25s var(--ease); }
|
||||||
|
.result.is-open .result__body { display: block; }
|
||||||
|
@keyframes slideDown { from { opacity: 0; transform: translateY(-4px);} to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 2px; width: 100%; background: transparent; overflow: hidden; position: relative;
|
||||||
|
margin: -2px 0 0;
|
||||||
|
}
|
||||||
|
.progress__bar {
|
||||||
|
position: absolute; left: 0; top: 0; height: 100%; width: 0; background: var(--accent);
|
||||||
|
transition: width .12s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Test step" rows inside a result */
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px 1fr auto;
|
||||||
|
gap: 10px; align-items: center;
|
||||||
|
padding: 5px 0; font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
color: var(--text-2);
|
||||||
|
border-left: 1px dashed var(--line-2);
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.step__icon { color: var(--pass); }
|
||||||
|
.step__icon.is-skip { color: var(--skip); }
|
||||||
|
.step__icon.is-info { color: var(--info); }
|
||||||
|
.step__dur { color: var(--text-4); }
|
||||||
|
.step__title em { color: var(--accent); font-style: normal; }
|
||||||
|
|
||||||
|
/* Generic content blocks for test bodies */
|
||||||
|
.block { padding: 6px 0; }
|
||||||
|
.block h4 { margin: 14px 0 6px; font-size: var(--text-sm); color: var(--text); font-family: var(--font-mono); }
|
||||||
|
.block h4::before { content: '// '; color: var(--text-4); }
|
||||||
|
.block p { margin: 4px 0; color: var(--text-2); max-width: 78ch; }
|
||||||
|
.block ul { margin: 4px 0; padding-left: 18px; color: var(--text-2); }
|
||||||
|
.block ul li { padding: 2px 0; }
|
||||||
|
.block strong { color: var(--text); }
|
||||||
|
.block a { color: var(--info); border-bottom: 1px dashed var(--info); }
|
||||||
|
.block a:hover { border-bottom-style: solid; }
|
||||||
|
|
||||||
|
.snippet {
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
background: var(--bg-2); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); padding: 14px 16px;
|
||||||
|
white-space: pre; overflow: auto;
|
||||||
|
display: grid; grid-template-columns: auto 1fr; gap: 0 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.snippet .ln { color: var(--text-4); user-select: none; text-align: right; }
|
||||||
|
.snippet .code .kw { color: var(--info); }
|
||||||
|
.snippet .code .fn { color: #dcdcaa; }
|
||||||
|
.snippet .code .str { color: #ce9178; }
|
||||||
|
.snippet .code .num { color: #b5cea8; }
|
||||||
|
.snippet .code .cm { color: #6a9955; font-style: italic; }
|
||||||
|
.snippet .code .tag { color: var(--accent); }
|
||||||
|
:root[data-theme='light'] .snippet .code .str { color: #a31515; }
|
||||||
|
:root[data-theme='light'] .snippet .code .fn { color: #795e26; }
|
||||||
|
:root[data-theme='light'] .snippet .code .cm { color: #008000; }
|
||||||
|
:root[data-theme='light'] .snippet .code .num { color: #098658; }
|
||||||
|
|
||||||
|
/* Cards (projects) */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px,1fr)); gap: 12px; margin: 8px 0; }
|
||||||
|
.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; background: var(--panel-2); }
|
||||||
|
.card h5 { margin: 0 0 4px; font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text); }
|
||||||
|
.card p { margin: 4px 0 0; color: var(--text-2); font-size: var(--text-xs); }
|
||||||
|
.card__tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Skills bars */
|
||||||
|
.skills { display: grid; gap: 10px; margin: 8px 0; }
|
||||||
|
.skill {
|
||||||
|
display: grid; grid-template-columns: 1fr 80px; gap: 12px; align-items: center;
|
||||||
|
padding: 8px 0; border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
.skill:first-child { border-top: 0; }
|
||||||
|
.skill__name { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-2); }
|
||||||
|
.skill__name strong { color: var(--text); }
|
||||||
|
.skill__bar { position: relative; height: 6px; background: var(--bg-2); border-radius: 3px; overflow: hidden; }
|
||||||
|
.skill__fill { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--info)); transition: width .8s var(--ease); }
|
||||||
|
.skill__pct { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); text-align: right; }
|
||||||
|
|
||||||
|
/* Contact grid */
|
||||||
|
.contact-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: 12px; margin: 8px 0; }
|
||||||
|
.contact-cell {
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
padding: 12px 14px; background: var(--panel-2);
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.contact-cell label { color: var(--text-4); display: block; font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 4px; }
|
||||||
|
.contact-cell a { color: var(--info); }
|
||||||
|
.contact-cell a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* CTA row */
|
||||||
|
.cta-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 16px 0 6px; }
|
||||||
|
.cta {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 16px; border-radius: var(--radius);
|
||||||
|
background: var(--accent); color: #062018; font-weight: 700;
|
||||||
|
border: 0; cursor: pointer; font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
text-decoration: none; letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.cta:hover { filter: brightness(1.05); }
|
||||||
|
.cta--ghost { background: transparent; color: var(--text); border: 1px solid var(--line-2); }
|
||||||
|
:root[data-theme='light'] .cta { color: #ffffff; }
|
||||||
|
:root[data-theme='light'] .cta--ghost { color: var(--text); }
|
||||||
|
|
||||||
|
/* ===================== TRACE PANE ===================== */
|
||||||
|
.trace-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--line); }
|
||||||
|
.trace-head__title { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text); }
|
||||||
|
.trace-head__meta { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
|
||||||
|
.legend { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.sw { width: 10px; height: 10px; border-radius: 2px; background: var(--text-4); display: inline-block; }
|
||||||
|
.sw--pass { background: var(--pass); }
|
||||||
|
.sw--info { background: var(--info); }
|
||||||
|
.trace { padding: 18px 24px; display: grid; gap: 6px; }
|
||||||
|
.trace-row { display: grid; grid-template-columns: 220px 1fr 90px; gap: 12px; align-items: center; font-family: var(--font-mono); font-size: 11.5px; }
|
||||||
|
.trace-row__label { color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.trace-row__label small { color: var(--text-4); }
|
||||||
|
.trace-row__track { position: relative; height: 16px; background: var(--bg-2); border-radius: 3px; }
|
||||||
|
.trace-row__bar { position: absolute; top: 2px; bottom: 2px; background: var(--pass); border-radius: 2px; opacity: .85; }
|
||||||
|
.trace-row__bar:hover { opacity: 1; }
|
||||||
|
.trace-row__dur { color: var(--text-4); text-align: right; }
|
||||||
|
.trace-rule { height: 1px; background: var(--line); margin: 8px 0 0; }
|
||||||
|
.trace-axis { display: grid; grid-template-columns: 220px 1fr 90px; gap: 12px; padding: 6px 24px 16px; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-4); }
|
||||||
|
.trace-axis__ticks { display: flex; justify-content: space-between; }
|
||||||
|
|
||||||
|
/* ===================== SOURCE PANE ===================== */
|
||||||
|
.source { font-family: var(--font-mono); font-size: var(--text-xs); padding: 14px 0; line-height: 1.65; }
|
||||||
|
.source__line { display: grid; grid-template-columns: 50px 1fr; gap: 14px; padding: 0 24px 0 0; }
|
||||||
|
.source__line:hover { background: var(--hover); }
|
||||||
|
.source__ln { color: var(--text-4); text-align: right; user-select: none; padding-left: 12px; border-right: 1px solid var(--line); }
|
||||||
|
.source__code { color: var(--text); white-space: pre; }
|
||||||
|
.source .kw { color: var(--info); }
|
||||||
|
.source .fn { color: #dcdcaa; }
|
||||||
|
.source .str { color: #ce9178; }
|
||||||
|
.source .num { color: #b5cea8; }
|
||||||
|
.source .cm { color: #6a9955; font-style: italic; }
|
||||||
|
.source .tag { color: var(--accent); }
|
||||||
|
:root[data-theme='light'] .source .str { color: #a31515; }
|
||||||
|
:root[data-theme='light'] .source .fn { color: #795e26; }
|
||||||
|
:root[data-theme='light'] .source .cm { color: #008000; }
|
||||||
|
:root[data-theme='light'] .source .num { color: #098658; }
|
||||||
|
|
||||||
|
/* ===================== CONSOLE PANE ===================== */
|
||||||
|
.console { font-family: var(--font-mono); font-size: var(--text-xs); padding: 8px 0; }
|
||||||
|
.console__line {
|
||||||
|
display: grid; grid-template-columns: 80px 60px 1fr; gap: 12px;
|
||||||
|
padding: 3px 24px;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
}
|
||||||
|
.console__line:hover { background: var(--hover); }
|
||||||
|
.console__ts { color: var(--text-4); }
|
||||||
|
.console__lvl { font-weight: 600; }
|
||||||
|
.lvl--info { color: var(--info); }
|
||||||
|
.lvl--ok { color: var(--pass); }
|
||||||
|
.lvl--warn { color: var(--skip); }
|
||||||
|
.lvl--err { color: var(--fail); }
|
||||||
|
.console__msg { color: var(--text-2); white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ===================== STATUSBAR ===================== */
|
||||||
|
.statusbar {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
background: var(--statusbar-bg); color: var(--statusbar-fg);
|
||||||
|
padding: 0 12px; font-family: var(--font-mono); font-size: 11px;
|
||||||
|
}
|
||||||
|
.sb__seg { display: inline-flex; align-items: center; gap: 6px; opacity: .95; }
|
||||||
|
.sb__seg--accent { background: rgba(255,255,255,.15); padding: 0 8px; height: 18px; border-radius: 2px; }
|
||||||
|
.sb__spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ===================== MOBILE SIDEBAR DRAWER ===================== */
|
||||||
|
.menu-btn { display: none; }
|
||||||
|
.sidebar-scrim { display: none; } /* hidden on desktop, escapes grid flow */
|
||||||
|
|
||||||
|
/* ===================== RESPONSIVE ===================== */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
body { grid-template-rows: 44px 1fr 22px; overflow: auto; }
|
||||||
|
.topbar { grid-template-columns: auto 1fr auto; padding: 0 8px; gap: 6px; }
|
||||||
|
.topbar__center { display: none; }
|
||||||
|
.topbar__left .crumb { display: none; }
|
||||||
|
.topbar__right .toggle { display: none; }
|
||||||
|
.topbar__right { gap: 4px; }
|
||||||
|
.btn--run span { display: none; }
|
||||||
|
.btn { padding: 0 8px; }
|
||||||
|
.menu-btn { display: inline-flex; }
|
||||||
|
|
||||||
|
.layout { grid-template-columns: 1fr; min-height: 0; }
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: 44px; bottom: 22px; left: 0;
|
||||||
|
width: 86%; max-width: 320px; z-index: 50;
|
||||||
|
transform: translateX(-100%); transition: transform .25s var(--ease);
|
||||||
|
box-shadow: 4px 0 12px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.sidebar.is-open { transform: translateX(0); }
|
||||||
|
.sidebar-scrim {
|
||||||
|
position: fixed; inset: 44px 0 22px 0; background: rgba(0,0,0,.45);
|
||||||
|
z-index: 49; display: none;
|
||||||
|
}
|
||||||
|
.sidebar-scrim.is-open { display: block; }
|
||||||
|
|
||||||
|
.tabs { overflow-x: auto; }
|
||||||
|
.tabs__meta { display: none; }
|
||||||
|
|
||||||
|
.hero { padding: 22px 18px 14px; }
|
||||||
|
.hero__title { font-size: 32px; }
|
||||||
|
.hero__sub { font-size: 12px; word-break: break-word; }
|
||||||
|
.hero__hint { font-size: 12.5px; }
|
||||||
|
|
||||||
|
.result { padding: 0 16px; }
|
||||||
|
.result__head { grid-template-columns: 16px 16px 1fr auto; gap: 8px; }
|
||||||
|
.result__rerun { display: none; }
|
||||||
|
.result__title { font-size: 12px; word-break: break-word; white-space: normal; }
|
||||||
|
.result__body { padding: 4px 0 18px 22px; }
|
||||||
|
.step { grid-template-columns: 16px 1fr auto; font-size: 11px; }
|
||||||
|
.step__title { word-break: break-word; white-space: normal; }
|
||||||
|
|
||||||
|
.trace-row, .trace-axis { grid-template-columns: 130px 1fr 56px; gap: 8px; font-size: 10.5px; }
|
||||||
|
.trace-row__label small { display: none; }
|
||||||
|
|
||||||
|
.source__line { grid-template-columns: 36px 1fr; }
|
||||||
|
.source { overflow-x: auto; }
|
||||||
|
|
||||||
|
.statusbar { font-size: 10.5px; gap: 8px; }
|
||||||
|
.statusbar .sb__seg:nth-child(n+6) { display: none; }
|
||||||
|
}
|
||||||
113
css/base.css
Normal file
113
css/base.css
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/* base.css — design tokens (Playwright trace viewer + VS Code dark+) */
|
||||||
|
:root {
|
||||||
|
/* Type */
|
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
--text-xs: 12px;
|
||||||
|
--text-sm: 13px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-md: 15px;
|
||||||
|
--text-lg: 18px;
|
||||||
|
--text-xl: 22px;
|
||||||
|
--text-2xl: 30px;
|
||||||
|
--text-3xl: 44px;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
|
||||||
|
/* Easing */
|
||||||
|
--ease: cubic-bezier(.2,.7,.2,1);
|
||||||
|
|
||||||
|
/* Status semantic */
|
||||||
|
--pass: #4ec9b0; /* playwright/teal-green */
|
||||||
|
--pass-2:#2ea889;
|
||||||
|
--fail: #f48771;
|
||||||
|
--skip: #d7ba7d;
|
||||||
|
--info: #569cd6;
|
||||||
|
--run: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark (default) — Playwright trace viewer / VS Code dark+ */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: #1e1e1e;
|
||||||
|
--bg-2: #181818;
|
||||||
|
--bg-3: #252526;
|
||||||
|
--panel: #1f1f1f;
|
||||||
|
--panel-2: #232323;
|
||||||
|
--line: #2d2d30;
|
||||||
|
--line-2: #3c3c3c;
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--text-2: #c8c8c8;
|
||||||
|
--text-3: #9da0a4;
|
||||||
|
--text-4: #6e7177;
|
||||||
|
--accent: #4ec9b0;
|
||||||
|
--selection: #264f78;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,.4);
|
||||||
|
--hover: rgba(255,255,255,.04);
|
||||||
|
--active: rgba(255,255,255,.07);
|
||||||
|
--kbd-bg: #2d2d30;
|
||||||
|
--tag-bg: #2a2d2e;
|
||||||
|
--tag-fg: #9cdcfe;
|
||||||
|
--green-arrow: #4ec9b0;
|
||||||
|
--topbar-bg: #2d2d30;
|
||||||
|
--statusbar-bg: #007acc;
|
||||||
|
--statusbar-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light */
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--bg-2: #f3f3f3;
|
||||||
|
--bg-3: #ececec;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #f8f8f8;
|
||||||
|
--line: #e6e6e6;
|
||||||
|
--line-2: #d4d4d4;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-2: #393939;
|
||||||
|
--text-3: #616161;
|
||||||
|
--text-4: #8a8a8a;
|
||||||
|
--accent: #0c8a6f;
|
||||||
|
--pass: #098e6a;
|
||||||
|
--pass-2: #0c8a6f;
|
||||||
|
--fail: #c72e22;
|
||||||
|
--skip: #b07c1f;
|
||||||
|
--info: #2169b8;
|
||||||
|
--selection:#add6ff;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||||
|
--hover: rgba(0,0,0,.04);
|
||||||
|
--active: rgba(0,0,0,.06);
|
||||||
|
--kbd-bg: #ececec;
|
||||||
|
--tag-bg: #e8f0fe;
|
||||||
|
--tag-fg: #1a73e8;
|
||||||
|
--green-arrow: #098e6a;
|
||||||
|
--topbar-bg: #f3f3f3;
|
||||||
|
--statusbar-bg: #007acc;
|
||||||
|
--statusbar-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
::selection { background: var(--selection); color: var(--text); }
|
||||||
|
button { font: inherit; color: inherit; }
|
||||||
|
input { font: inherit; color: inherit; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
code, pre, .mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 6px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-4); }
|
||||||
152
index.html
Normal file
152
index.html
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Ilia Dobkin — portfolio.spec.ts</title>
|
||||||
|
<meta name="description" content="Ilia Dobkin — Senior SDET portfolio, styled as a Playwright test runner." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="css/base.css" />
|
||||||
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ============== TOP BAR ============== -->
|
||||||
|
<header class="topbar" role="banner">
|
||||||
|
<div class="topbar__left">
|
||||||
|
<button class="btn btn--ghost menu-btn" id="menu-btn" aria-label="Open test explorer" title="Test Explorer">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<a class="brand" href="#" aria-label="Ilia Dobkin home">
|
||||||
|
<svg class="brand__logo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
|
<!-- Custom mark: a play-triangle inside a soft rounded square, evoking a runner -->
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" fill="currentColor" opacity="0.12"/>
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M13 10.5 L23 16 L13 21.5 Z" fill="#4ec9b0"/>
|
||||||
|
<circle cx="9" cy="16" r="1.4" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span class="brand__name">ilia.dobkin</span>
|
||||||
|
</a>
|
||||||
|
<span class="crumb"><span class="crumb__sep">/</span> tests <span class="crumb__sep">/</span> <strong>portfolio.spec.ts</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar__center">
|
||||||
|
<div class="status-pill" id="status-pill">
|
||||||
|
<span class="dot dot--idle" id="status-dot"></span>
|
||||||
|
<span id="status-text">idle</span>
|
||||||
|
<span class="status-counts">
|
||||||
|
<span class="cnt cnt--pass" title="passed">✓ <em id="cnt-pass">0</em></span>
|
||||||
|
<span class="cnt cnt--fail" title="failed">✗ <em id="cnt-fail">0</em></span>
|
||||||
|
<span class="cnt cnt--skip" title="skipped">⊘ <em id="cnt-skip">0</em></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar__right">
|
||||||
|
<button class="btn btn--run" id="run-all" title="Run all tests">
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 3 L13 8 L4 13 Z" fill="currentColor"/></svg>
|
||||||
|
<span>Run all</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--ghost" id="stop-all" title="Stop" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="1.5" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--ghost" id="reset-all" title="Reset">
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 3.5a4.5 4.5 0 1 1-4.39 5.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M8 1 L8 5 L4 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<label class="toggle" title="Headed mode (slower animations)">
|
||||||
|
<input type="checkbox" id="headed" />
|
||||||
|
<span>--headed</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--ghost" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M11 8a3 3 0 1 1-3-3 3 3 0 0 0 3 3z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============== MAIN LAYOUT ============== -->
|
||||||
|
<main class="layout">
|
||||||
|
<div class="sidebar-scrim" id="sidebar-scrim"></div>
|
||||||
|
<!-- Sidebar: Test Explorer -->
|
||||||
|
<aside class="sidebar" id="sidebar" aria-label="Test Explorer">
|
||||||
|
<div class="sidebar__head">
|
||||||
|
<span class="sidebar__title">TEST EXPLORER</span>
|
||||||
|
<button class="iconbtn" id="expand-all" title="Expand all" aria-label="Expand all"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M3 6 L8 11 L13 6" stroke="currentColor" stroke-width="1.6" fill="none"/></svg></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter">
|
||||||
|
<span class="filter__icon">⌕</span>
|
||||||
|
<input id="grep" type="text" placeholder='--grep "@playwright"' autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags" id="tag-bar" aria-label="Filter by tag"></div>
|
||||||
|
|
||||||
|
<nav class="tree" id="tree" aria-label="Tests"></nav>
|
||||||
|
|
||||||
|
<div class="sidebar__foot">
|
||||||
|
<span>v1.0.0 · 9 tests</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Pane -->
|
||||||
|
<section class="main" aria-label="Test results">
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button class="tab is-active" data-tab="report" role="tab">Report</button>
|
||||||
|
<button class="tab" data-tab="trace" role="tab">Trace</button>
|
||||||
|
<button class="tab" data-tab="source" role="tab">Source</button>
|
||||||
|
<button class="tab" data-tab="console" role="tab">Console</button>
|
||||||
|
<div class="tabs__spacer"></div>
|
||||||
|
<span class="tabs__meta" id="tabs-meta">portfolio.spec.ts · 9 tests</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report -->
|
||||||
|
<div class="pane is-active" id="pane-report" role="tabpanel">
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero__tag">describe</div>
|
||||||
|
<h1 class="hero__title">Ilia Dobkin</h1>
|
||||||
|
<p class="hero__sub">Senior SDET · Toronto, ON · <span class="mono">test.describe("portfolio")</span></p>
|
||||||
|
<p class="hero__hint">Click the green <span class="kbd">▶</span> next to any test to run it — or press <span class="kbd">Run all</span> above.</p>
|
||||||
|
</div>
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trace -->
|
||||||
|
<div class="pane" id="pane-trace" role="tabpanel">
|
||||||
|
<div class="trace-head">
|
||||||
|
<div class="trace-head__title">Career timeline · trace viewer</div>
|
||||||
|
<div class="trace-head__meta"><span class="legend"><i class="sw sw--pass"></i> pass</span><span class="legend"><i class="sw sw--info"></i> milestone</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="trace" id="trace"></div>
|
||||||
|
<div class="trace-rule"></div>
|
||||||
|
<div class="trace-axis" id="trace-axis"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source -->
|
||||||
|
<div class="pane" id="pane-source" role="tabpanel">
|
||||||
|
<div class="source" id="source"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console -->
|
||||||
|
<div class="pane" id="pane-console" role="tabpanel">
|
||||||
|
<div class="console" id="console"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ============== BOTTOM STATUS BAR ============== -->
|
||||||
|
<footer class="statusbar" role="contentinfo">
|
||||||
|
<span class="sb__seg">⎇ main</span>
|
||||||
|
<span class="sb__seg">● 0</span>
|
||||||
|
<span class="sb__seg" id="sb-runtime">runtime: 0ms</span>
|
||||||
|
<span class="sb__spacer"></span>
|
||||||
|
<span class="sb__seg">TypeScript</span>
|
||||||
|
<span class="sb__seg">UTF-8</span>
|
||||||
|
<span class="sb__seg">LF</span>
|
||||||
|
<span class="sb__seg sb__seg--accent">Playwright 1.49</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/data.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
477
js/app.js
Normal file
477
js/app.js
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
/* app.js — Playwright-runner UI behavior */
|
||||||
|
(function(){
|
||||||
|
const $ = (sel,root=document)=>root.querySelector(sel);
|
||||||
|
const $$ = (sel,root=document)=>Array.from(root.querySelectorAll(sel));
|
||||||
|
const data = window.PORTFOLIO;
|
||||||
|
const tests = data.suite.tests;
|
||||||
|
|
||||||
|
// Test state: idle | running | passed
|
||||||
|
const state = Object.fromEntries(tests.map(t=>[t.id, { status:'idle', runtime:0 }]));
|
||||||
|
let isRunningAll = false;
|
||||||
|
let activeTimers = [];
|
||||||
|
const headed = () => $('#headed').checked;
|
||||||
|
|
||||||
|
// ============ ICONS ============
|
||||||
|
const ICONS = {
|
||||||
|
play: '<svg viewBox="0 0 16 16"><path d="M5 3 L12 8 L5 13 Z" fill="currentColor"/></svg>',
|
||||||
|
check: '<svg viewBox="0 0 16 16"><path d="M3.5 8 L7 11.5 L13 5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||||
|
dot: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.6" fill="currentColor"/></svg>',
|
||||||
|
spin: '<svg viewBox="0 0 16 16" class="spin"><circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="20 12" stroke-linecap="round"/></svg>',
|
||||||
|
chev: '<svg viewBox="0 0 16 16"><path d="M6 4 L10 8 L6 12" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||||
|
rerun: '<svg viewBox="0 0 16 16"><path d="M12.5 6A4.5 4.5 0 1 1 8 3.5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/><path d="M12 2 L13 6 L9 6" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||||
|
};
|
||||||
|
function statusIcon(s){
|
||||||
|
if (s==='running') return ICONS.spin;
|
||||||
|
if (s==='passed') return ICONS.check;
|
||||||
|
return ICONS.dot;
|
||||||
|
}
|
||||||
|
function statusClass(s){
|
||||||
|
if (s==='running') return 'icon--run';
|
||||||
|
if (s==='passed') return 'icon--pass';
|
||||||
|
return 'icon--idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format test title with syntax highlight
|
||||||
|
function titleHTML(t){
|
||||||
|
return `<span class="kw">test</span>(<span class="str">'${t.title}'</span>)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SIDEBAR (tree) ============
|
||||||
|
function renderTree(){
|
||||||
|
const wrap = $('#tree');
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="suite" data-suite>
|
||||||
|
<div class="suite__head">
|
||||||
|
<span class="suite__caret">${ICONS.chev}</span>
|
||||||
|
<span class="suite__name"><span class="kw">describe</span>(<em>'${data.suite.name}'</em>)</span>
|
||||||
|
</div>
|
||||||
|
${tests.map(renderTreeTest).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
wrap.addEventListener('click', e=>{
|
||||||
|
const head = e.target.closest('.suite__head');
|
||||||
|
if (head) { head.parentElement.classList.toggle('collapsed'); return; }
|
||||||
|
const runBtn = e.target.closest('.test__run');
|
||||||
|
if (runBtn) { e.stopPropagation(); runTest(runBtn.dataset.id); return; }
|
||||||
|
const row = e.target.closest('.test');
|
||||||
|
if (row) { selectTest(row.dataset.id, true); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderTreeTest(t){
|
||||||
|
const s = state[t.id];
|
||||||
|
return `
|
||||||
|
<div class="test ${s.status==='running'?'is-running':''} ${s.status==='passed'?'is-passed':''}" data-id="${t.id}" data-tags="${t.tags.join(' ')}">
|
||||||
|
<button class="test__run" data-id="${t.id}" title="Run">${ICONS.play}</button>
|
||||||
|
<span class="test__icon ${statusClass(s.status)}">${statusIcon(s.status)}</span>
|
||||||
|
<span class="test__title">${titleHTML(t)}</span>
|
||||||
|
<span class="test__dur">${s.runtime?formatMs(s.runtime):''}</span>
|
||||||
|
<div class="test__tags">${t.tags.map(x=>`<span class="tag">${x}</span>`).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function refreshTreeRow(id){
|
||||||
|
const t = tests.find(x=>x.id===id);
|
||||||
|
const s = state[id];
|
||||||
|
const row = $(`.test[data-id="${id}"]`);
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.toggle('is-running', s.status==='running');
|
||||||
|
row.classList.toggle('is-passed', s.status==='passed');
|
||||||
|
const iconEl = row.querySelector('.test__icon');
|
||||||
|
iconEl.className = `test__icon ${statusClass(s.status)}`;
|
||||||
|
iconEl.innerHTML = statusIcon(s.status);
|
||||||
|
row.querySelector('.test__dur').textContent = s.runtime?formatMs(s.runtime):'';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ TAG BAR ============
|
||||||
|
let activeTags = new Set();
|
||||||
|
function renderTagBar(){
|
||||||
|
const bar = $('#tag-bar');
|
||||||
|
bar.innerHTML = data.tags.map(t=>`<span class="tag" data-tag="${t}">${t}</span>`).join('');
|
||||||
|
bar.addEventListener('click', e=>{
|
||||||
|
const el = e.target.closest('.tag'); if (!el) return;
|
||||||
|
const tag = el.dataset.tag;
|
||||||
|
if (activeTags.has(tag)) activeTags.delete(tag);
|
||||||
|
else activeTags.add(tag);
|
||||||
|
bar.querySelectorAll('.tag').forEach(t=>{
|
||||||
|
t.classList.toggle('is-active', activeTags.has(t.dataset.tag));
|
||||||
|
});
|
||||||
|
// Reflect into grep input
|
||||||
|
$('#grep').value = Array.from(activeTags).join(' ');
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(){
|
||||||
|
const grep = $('#grep').value.trim().toLowerCase();
|
||||||
|
const wantedTags = grep.split(/\s+/).filter(s=>s.startsWith('@'));
|
||||||
|
const text = grep.replace(/@\S+/g,'').trim();
|
||||||
|
$$('.test').forEach(row=>{
|
||||||
|
const id = row.dataset.id;
|
||||||
|
const t = tests.find(x=>x.id===id);
|
||||||
|
const tagsOK = wantedTags.length===0 || wantedTags.every(tg => t.tags.includes(tg));
|
||||||
|
const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text);
|
||||||
|
row.style.display = (tagsOK && textOK) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ MAIN PANE RESULTS ============
|
||||||
|
function renderResults(){
|
||||||
|
const wrap = $('#results');
|
||||||
|
wrap.innerHTML = tests.map(t=>{
|
||||||
|
const s = state[t.id];
|
||||||
|
return `
|
||||||
|
<article class="result" id="result-${t.id}" data-id="${t.id}">
|
||||||
|
<div class="result__head">
|
||||||
|
<span class="result__caret">${ICONS.chev}</span>
|
||||||
|
<span class="result__status ${statusClass(s.status)}">${statusIcon(s.status)}</span>
|
||||||
|
<span class="result__title">${titleHTML(t)}</span>
|
||||||
|
<span class="result__dur">${s.runtime?formatMs(s.runtime):''}</span>
|
||||||
|
<button class="result__rerun" title="Run / re-run" data-id="${t.id}">${ICONS.rerun}</button>
|
||||||
|
</div>
|
||||||
|
<div class="progress"><div class="progress__bar" id="bar-${t.id}"></div></div>
|
||||||
|
<div class="result__body" id="body-${t.id}"></div>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
wrap.addEventListener('click', e=>{
|
||||||
|
const rerun = e.target.closest('.result__rerun');
|
||||||
|
if (rerun) { e.stopPropagation(); runTest(rerun.dataset.id); return; }
|
||||||
|
const head = e.target.closest('.result__head');
|
||||||
|
if (head) {
|
||||||
|
const art = head.parentElement;
|
||||||
|
const id = art.dataset.id;
|
||||||
|
// If passed, toggle. If idle, run it.
|
||||||
|
if (state[id].status === 'idle') { runTest(id); }
|
||||||
|
else { art.classList.toggle('is-open'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function refreshResultRow(id){
|
||||||
|
const t = tests.find(x=>x.id===id);
|
||||||
|
const s = state[id];
|
||||||
|
const art = $(`#result-${id}`);
|
||||||
|
if (!art) return;
|
||||||
|
const ico = art.querySelector('.result__status');
|
||||||
|
ico.className = `result__status ${statusClass(s.status)}`;
|
||||||
|
ico.innerHTML = statusIcon(s.status);
|
||||||
|
art.querySelector('.result__dur').textContent = s.runtime?formatMs(s.runtime):'';
|
||||||
|
if (s.status === 'passed' && !art.dataset.populated) {
|
||||||
|
const body = $(`#body-${id}`);
|
||||||
|
// Steps + custom content
|
||||||
|
const stepsHTML = t.steps.map(st=>`
|
||||||
|
<div class="step">
|
||||||
|
<span class="step__icon ${st.kind==='info'?'is-info':st.kind==='skip'?'is-skip':''}">${st.kind==='info'?ICONS.dot:ICONS.check}</span>
|
||||||
|
<span class="step__title">${st.title}</span>
|
||||||
|
<span class="step__dur">${formatMs(st.dur)}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
body.innerHTML = `<div style="margin:0 0 14px">${stepsHTML}</div>${t.render()}`;
|
||||||
|
art.dataset.populated = '1';
|
||||||
|
art.classList.add('is-open');
|
||||||
|
// Animate skill bars if present
|
||||||
|
$$('.skill__fill', body).forEach(el => requestAnimationFrame(()=> el.style.width = el.dataset.pct + '%'));
|
||||||
|
// Hook resume buttons
|
||||||
|
const dl = $('#dl-resume', body); if (dl) dl.addEventListener('click', downloadResume);
|
||||||
|
const pr = $('#print-resume', body); if (pr) pr.addEventListener('click', ()=>window.print());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RUN ENGINE ============
|
||||||
|
function selectTest(id, scroll){
|
||||||
|
$$('.test').forEach(r=>r.classList.toggle('is-selected', r.dataset.id===id));
|
||||||
|
if (scroll) {
|
||||||
|
const el = $(`#result-${id}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior:'smooth', block:'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(id){
|
||||||
|
const t = tests.find(x=>x.id===id);
|
||||||
|
if (!t) return;
|
||||||
|
const s = state[id];
|
||||||
|
if (s.status === 'running') return;
|
||||||
|
// reset
|
||||||
|
s.status = 'running'; s.runtime = 0;
|
||||||
|
refreshTreeRow(id); refreshResultRow(id); updateStatusbar();
|
||||||
|
selectTest(id, true);
|
||||||
|
consoleLine('info', `▶ running test('${t.title}')`);
|
||||||
|
|
||||||
|
const dur = headed() ? t.duration * 2.5 : t.duration;
|
||||||
|
const start = performance.now();
|
||||||
|
const bar = $(`#bar-${id}`);
|
||||||
|
bar.style.width = '0%';
|
||||||
|
|
||||||
|
await tween(dur, (p)=>{ bar.style.width = (p*100) + '%'; });
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start);
|
||||||
|
s.runtime = elapsed; s.status = 'passed';
|
||||||
|
bar.style.width = '100%';
|
||||||
|
setTimeout(()=>{ bar.style.transition='opacity .4s'; bar.style.opacity='0'; }, 200);
|
||||||
|
// Clear populated flag so it re-renders fresh on re-run
|
||||||
|
const art = $(`#result-${id}`); if (art) art.dataset.populated = '';
|
||||||
|
refreshTreeRow(id); refreshResultRow(id); updateStatusbar();
|
||||||
|
consoleLine('ok', `✓ test('${t.title}') passed in ${formatMs(elapsed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAll(){
|
||||||
|
if (isRunningAll) return;
|
||||||
|
isRunningAll = true;
|
||||||
|
$('#run-all').disabled = true;
|
||||||
|
$('#stop-all').disabled = false;
|
||||||
|
consoleLine('info', `▶ playwright test (workers=1)`);
|
||||||
|
for (const t of tests) {
|
||||||
|
const row = $(`.test[data-id="${t.id}"]`);
|
||||||
|
if (row && row.style.display === 'none') continue; // respect filter
|
||||||
|
// reset to idle if previously passed
|
||||||
|
state[t.id].status = 'idle'; state[t.id].runtime = 0;
|
||||||
|
const art = $(`#result-${t.id}`); if (art) { art.dataset.populated=''; const b=$(`#bar-${t.id}`); if(b){b.style.width='0%';b.style.opacity='1';b.style.transition='';}}
|
||||||
|
refreshTreeRow(t.id); refreshResultRow(t.id);
|
||||||
|
await runTest(t.id);
|
||||||
|
if (!isRunningAll) break;
|
||||||
|
}
|
||||||
|
isRunningAll = false;
|
||||||
|
$('#run-all').disabled = false;
|
||||||
|
$('#stop-all').disabled = true;
|
||||||
|
const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0);
|
||||||
|
consoleLine('ok', `Test suite finished — ${countPassed()} passed in ${formatMs(total)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll(){
|
||||||
|
isRunningAll = false;
|
||||||
|
for (const t of tests) {
|
||||||
|
state[t.id] = { status:'idle', runtime:0 };
|
||||||
|
const art = $(`#result-${t.id}`);
|
||||||
|
if (art) {
|
||||||
|
art.dataset.populated = '';
|
||||||
|
art.classList.remove('is-open');
|
||||||
|
const body = $(`#body-${t.id}`); if (body) body.innerHTML='';
|
||||||
|
const b = $(`#bar-${t.id}`); if (b){ b.style.width='0%'; b.style.opacity='1'; b.style.transition=''; }
|
||||||
|
}
|
||||||
|
refreshTreeRow(t.id); refreshResultRow(t.id);
|
||||||
|
}
|
||||||
|
$('#console').innerHTML = '';
|
||||||
|
updateStatusbar();
|
||||||
|
consoleLine('info', 'reset · all tests returned to idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ STATUS / COUNTS ============
|
||||||
|
function countPassed(){ return Object.values(state).filter(s=>s.status==='passed').length; }
|
||||||
|
function updateStatusbar(){
|
||||||
|
const passed = countPassed();
|
||||||
|
const running = Object.values(state).filter(s=>s.status==='running').length;
|
||||||
|
$('#cnt-pass').textContent = passed;
|
||||||
|
$('#cnt-fail').textContent = 0;
|
||||||
|
$('#cnt-skip').textContent = 0;
|
||||||
|
const dot = $('#status-dot'); const txt = $('#status-text');
|
||||||
|
if (running) { dot.className='dot dot--running'; txt.textContent = 'running'; }
|
||||||
|
else if (passed === tests.length) { dot.className='dot dot--pass'; txt.textContent='all passed'; }
|
||||||
|
else if (passed > 0) { dot.className='dot dot--pass'; txt.textContent='partial'; }
|
||||||
|
else { dot.className='dot dot--idle'; txt.textContent='idle'; }
|
||||||
|
const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0);
|
||||||
|
$('#sb-runtime').textContent = `runtime: ${formatMs(total)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ CONSOLE ============
|
||||||
|
function consoleLine(lvl, msg){
|
||||||
|
const el = $('#console');
|
||||||
|
const ts = new Date().toLocaleTimeString('en-CA', { hour12:false });
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'console__line';
|
||||||
|
line.innerHTML = `<span class="console__ts">${ts}</span><span class="console__lvl lvl--${lvl}">${lvl.toUpperCase()}</span><span class="console__msg">${msg}</span>`;
|
||||||
|
el.appendChild(line);
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ TABS ============
|
||||||
|
function initTabs(){
|
||||||
|
$$('.tab').forEach(tab=>{
|
||||||
|
tab.addEventListener('click', ()=>{
|
||||||
|
const id = tab.dataset.tab;
|
||||||
|
$$('.tab').forEach(t=>t.classList.toggle('is-active', t===tab));
|
||||||
|
$$('.pane').forEach(p=>p.classList.toggle('is-active', p.id === `pane-${id}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ TRACE PANE ============
|
||||||
|
function renderTrace(){
|
||||||
|
// Build a career timeline: each role gets a bar relative to a global timeline
|
||||||
|
const trace = $('#trace');
|
||||||
|
const items = data.experience.map(e => {
|
||||||
|
const m = e.when.match(/(\w+\s+\d{4})\s*[–-]\s*(\w+\s+\d{4})/i);
|
||||||
|
if (!m) return null;
|
||||||
|
return { ...e, start: parseMon(m[1]), end: parseMon(m[2]) };
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
const min = Math.min(...items.map(i=>i.start.getTime()));
|
||||||
|
const max = Math.max(...items.map(i=>i.end.getTime()));
|
||||||
|
const span = max - min;
|
||||||
|
|
||||||
|
trace.innerHTML = items.map(it=>{
|
||||||
|
const left = ((it.start - min) / span) * 100;
|
||||||
|
const width = Math.max(2, ((it.end - it.start) / span) * 100);
|
||||||
|
const months = Math.round((it.end - it.start)/(1000*60*60*24*30));
|
||||||
|
return `
|
||||||
|
<div class="trace-row" title="${_esc(it.company)} · ${_esc(it.when)}">
|
||||||
|
<div class="trace-row__label">${_esc(it.company)} <small>· ${_esc(it.role)}</small></div>
|
||||||
|
<div class="trace-row__track">
|
||||||
|
<div class="trace-row__bar" style="left:${left}%;width:${width}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="trace-row__dur">${months}mo</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Axis ticks: years from min to max
|
||||||
|
const axis = $('#trace-axis');
|
||||||
|
const yStart = new Date(min).getFullYear();
|
||||||
|
const yEnd = new Date(max).getFullYear();
|
||||||
|
const years = [];
|
||||||
|
for (let y=yStart; y<=yEnd; y+=Math.max(1, Math.ceil((yEnd-yStart)/8))) years.push(y);
|
||||||
|
if (years[years.length-1] !== yEnd) years.push(yEnd);
|
||||||
|
axis.innerHTML = `<div></div><div class="trace-axis__ticks">${years.map(y=>`<span>${y}</span>`).join('')}</div><div></div>`;
|
||||||
|
}
|
||||||
|
function parseMon(s){
|
||||||
|
const [mon,yr] = s.split(/\s+/);
|
||||||
|
const idx = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']
|
||||||
|
.indexOf(mon.slice(0,3).toLowerCase());
|
||||||
|
return new Date(parseInt(yr), idx<0?0:idx, 1);
|
||||||
|
}
|
||||||
|
function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
|
|
||||||
|
// ============ SOURCE PANE ============
|
||||||
|
function renderSource(){
|
||||||
|
const lines = [
|
||||||
|
['<span class="cm">// portfolio.spec.ts — Senior SDET portfolio, expressed as a Playwright test suite</span>'],
|
||||||
|
['<span class="kw">import</span> { test, expect } <span class="kw">from</span> <span class="str">\'@playwright/test\'</span>;'],
|
||||||
|
['<span class="kw">import</span> { person, experience, skills, projects } <span class="kw">from</span> <span class="str">\'./fixtures/ilia\'</span>;'],
|
||||||
|
[''],
|
||||||
|
[`test.describe(<span class="str">'${data.suite.name}'</span>, () => {`],
|
||||||
|
[''],
|
||||||
|
];
|
||||||
|
tests.forEach(t=>{
|
||||||
|
const tagStr = t.tags.length ? ` <span class="cm">// ${t.tags.join(' ')}</span>` : '';
|
||||||
|
lines.push([` test(<span class="str">'${t.title}'</span>, <span class="kw">async</span> ({ page }) => {${tagStr}`]);
|
||||||
|
t.steps.forEach(s=>{
|
||||||
|
lines.push([` <span class="kw">await</span> test.step(<span class="str">'${s.title.replace(/'/g,"\\'")}'</span>, <span class="kw">async</span> () => { <span class="cm">/* ${s.dur}ms */</span> });`]);
|
||||||
|
});
|
||||||
|
lines.push([` });`]);
|
||||||
|
lines.push(['']);
|
||||||
|
});
|
||||||
|
lines.push(['});']);
|
||||||
|
|
||||||
|
$('#source').innerHTML = lines.map((row,i)=>`
|
||||||
|
<div class="source__line">
|
||||||
|
<span class="source__ln">${i+1}</span>
|
||||||
|
<span class="source__code">${row[0]}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RESUME DOWNLOAD ============
|
||||||
|
function downloadResume(){
|
||||||
|
// Build a single-page HTML resume as a blob, open it in a new tab and trigger print
|
||||||
|
const p = data.person;
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${p.first} ${p.last} — Resume</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', system-ui, sans-serif; max-width: 820px; margin: 40px auto; padding: 0 36px; color:#1f1f1f; }
|
||||||
|
h1 { margin: 0 0 4px; font-size: 28px; letter-spacing: -.01em; }
|
||||||
|
h2 { border-bottom: 1px solid #ddd; padding-bottom: 4px; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; margin: 22px 0 10px; color: #444; }
|
||||||
|
h3 { font-size: 14px; margin: 14px 0 2px; }
|
||||||
|
.meta { color: #555; font-size: 12.5px; margin: 2px 0 6px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||||
|
ul { margin: 4px 0 0; padding-left: 18px; font-size: 13px; }
|
||||||
|
li { margin: 3px 0; }
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: baseline; }
|
||||||
|
.contact { color:#555; font-size: 12.5px; font-family:'JetBrains Mono', monospace; }
|
||||||
|
.blurb { font-size: 13px; line-height: 1.55; color:#333; }
|
||||||
|
.skills-list { font-size: 13px; line-height: 1.6; }
|
||||||
|
@media print { body { margin: 0; padding: 0 28px; } h2 { page-break-after: avoid; } h3 { page-break-after: avoid; } }
|
||||||
|
</style></head><body>
|
||||||
|
<div class="head"><h1>${p.first} ${p.last}</h1><div class="contact">${p.email} · ${p.phone}</div></div>
|
||||||
|
<div class="meta">${p.title} · ${p.location} · ${p.linkedin}</div>
|
||||||
|
<p class="blurb">${p.blurb}</p>
|
||||||
|
<h2>Experience</h2>
|
||||||
|
${data.experience.map(e=>`
|
||||||
|
<h3>${e.company} — ${e.role}</h3>
|
||||||
|
<div class="meta">${e.when} · ${e.where}</div>
|
||||||
|
<ul>${e.bullets.map(b=>`<li>${b}</li>`).join('')}</ul>
|
||||||
|
`).join('')}
|
||||||
|
<h2>Skills</h2>
|
||||||
|
<ul class="skills-list">${data.skills.map(s=>`<li>${s.name.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')}</li>`).join('')}</ul>
|
||||||
|
<h2>Projects</h2>
|
||||||
|
${data.projects.map(p=>`<h3>${p.name}</h3><p style="font-size:13px;margin:4px 0">${p.desc}</p>`).join('')}
|
||||||
|
<script>window.addEventListener('load', () => setTimeout(()=>window.print(), 300));</script>
|
||||||
|
</body></html>`;
|
||||||
|
const blob = new Blob([html], { type:'text/html' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
consoleLine('ok', '⇩ resume.pdf generated — print dialog opened in new tab');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ THEME TOGGLE ============
|
||||||
|
function initTheme(){
|
||||||
|
const saved = localStorage.getItem('theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
$('#theme-toggle').addEventListener('click', ()=>{
|
||||||
|
const cur = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = cur === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HELPERS ============
|
||||||
|
function formatMs(ms){
|
||||||
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||||
|
return `${(ms/1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
function tween(duration, onUpdate){
|
||||||
|
return new Promise(resolve=>{
|
||||||
|
const start = performance.now();
|
||||||
|
function frame(now){
|
||||||
|
const p = Math.min(1, (now-start)/duration);
|
||||||
|
onUpdate(p);
|
||||||
|
if (p < 1) requestAnimationFrame(frame); else resolve();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ INIT ============
|
||||||
|
function init(){
|
||||||
|
initTheme();
|
||||||
|
renderTagBar();
|
||||||
|
renderTree();
|
||||||
|
renderResults();
|
||||||
|
renderTrace();
|
||||||
|
renderSource();
|
||||||
|
initTabs();
|
||||||
|
updateStatusbar();
|
||||||
|
|
||||||
|
$('#grep').addEventListener('input', applyFilter);
|
||||||
|
$('#run-all').addEventListener('click', runAll);
|
||||||
|
$('#stop-all').addEventListener('click', ()=>{ isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); });
|
||||||
|
$('#reset-all').addEventListener('click', resetAll);
|
||||||
|
$('#expand-all').addEventListener('click', ()=>{
|
||||||
|
$$('.suite').forEach(s=>s.classList.remove('collapsed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile drawer
|
||||||
|
const sidebar = $('#sidebar');
|
||||||
|
const scrim = $('#sidebar-scrim');
|
||||||
|
function closeDrawer(){ sidebar.classList.remove('is-open'); scrim.classList.remove('is-open'); }
|
||||||
|
$('#menu-btn').addEventListener('click', ()=>{
|
||||||
|
sidebar.classList.toggle('is-open');
|
||||||
|
scrim.classList.toggle('is-open');
|
||||||
|
});
|
||||||
|
scrim.addEventListener('click', closeDrawer);
|
||||||
|
// Close drawer when a test is selected on mobile
|
||||||
|
sidebar.addEventListener('click', e => {
|
||||||
|
if (window.innerWidth <= 900 && (e.target.closest('.test') || e.target.closest('.test__run'))) {
|
||||||
|
setTimeout(closeDrawer, 120);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleLine('info', `Running ${tests.length} tests using 1 worker`);
|
||||||
|
|
||||||
|
// Friendly nudge: animate the Run All button briefly on first load
|
||||||
|
setTimeout(()=> $('#run-all').classList.add('pulse'), 600);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
})();
|
||||||
404
js/data.js
Normal file
404
js/data.js
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
/* data.js — single source of truth for the portfolio "test suite" */
|
||||||
|
window.PORTFOLIO = {
|
||||||
|
person: {
|
||||||
|
first: "Ilia",
|
||||||
|
last: "Dobkin",
|
||||||
|
title: "Senior SDET",
|
||||||
|
location: "Toronto, Ontario, Canada",
|
||||||
|
email: "idobkin@gmail.com",
|
||||||
|
phone: "+1 (647) 987-2792",
|
||||||
|
linkedin: "https://www.linkedin.com/in/idobkin/",
|
||||||
|
gitea: "https://git.levkin.ca",
|
||||||
|
site: "https://iliadobkin.com",
|
||||||
|
blurb:
|
||||||
|
"Senior SDET with 20+ years delivering automation and release confidence for audit/financial software and regulated web (including real-money iGaming). Owns E2E and API test strategy — Playwright, Cypress, Selenium — contract testing against Swagger/OpenAPI, and performance baselines, integrated into CI/CD for fast, reliable feedback.",
|
||||||
|
headline:
|
||||||
|
"Built 300+ Playwright E2E + 250+ API tests; parallel CI runs consistently above ~95% pass rate; manual regression effort cut by ~50%.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Master tag palette — used for the filter bar at top of sidebar
|
||||||
|
tags: [
|
||||||
|
"@playwright","@cypress","@selenium","@api","@ci","@docker","@terraform",
|
||||||
|
"@cloud","@a11y","@perf","@bdd","@ai","@infra","@leadership"
|
||||||
|
],
|
||||||
|
|
||||||
|
// The "test suite" — each entry maps to a section on the page
|
||||||
|
suite: {
|
||||||
|
name: "Ilia Dobkin · portfolio",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
id: "about",
|
||||||
|
title: 'should introduce Ilia Dobkin',
|
||||||
|
tags: ["@playwright","@leadership"],
|
||||||
|
duration: 142,
|
||||||
|
steps: [
|
||||||
|
{ kind: "info", title: 'navigate to /about', dur: 12 },
|
||||||
|
{ kind: "ok", title: 'render bio', dur: 48 },
|
||||||
|
{ kind: "ok", title: 'assert credentials', dur: 82 },
|
||||||
|
],
|
||||||
|
render: renderAbout
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "experience",
|
||||||
|
title: 'should list senior SDET experience',
|
||||||
|
tags: ["@playwright","@api","@ci","@cloud","@leadership"],
|
||||||
|
duration: 1280,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'expect(roles.length).toBe(8)', dur: 38 },
|
||||||
|
{ kind: "ok", title: 'assert chronological order', dur: 24 },
|
||||||
|
{ kind: "ok", title: 'verify each role.bullets.length > 0', dur: 96 },
|
||||||
|
],
|
||||||
|
render: renderExperience
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skills",
|
||||||
|
title: 'should expose @-tagged skills',
|
||||||
|
tags: ["@playwright","@cypress","@selenium","@api","@bdd","@ci","@docker","@terraform","@cloud","@a11y","@perf","@ai"],
|
||||||
|
duration: 412,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'load tag registry', dur: 54 },
|
||||||
|
{ kind: "ok", title: 'assign proficiency', dur: 84 },
|
||||||
|
],
|
||||||
|
render: renderSkills
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "projects",
|
||||||
|
title: 'should showcase self-hosted projects',
|
||||||
|
tags: ["@infra","@ai","@playwright","@docker"],
|
||||||
|
duration: 680,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'discover proxmox homelab', dur: 120 },
|
||||||
|
{ kind: "ok", title: 'validate MCP server', dur: 80 },
|
||||||
|
],
|
||||||
|
render: renderProjects
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stack",
|
||||||
|
title: 'should describe daily stack',
|
||||||
|
tags: ["@docker","@terraform","@infra","@ai"],
|
||||||
|
duration: 320,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'list editors and runtimes', dur: 40 },
|
||||||
|
{ kind: "ok", title: 'list local LLM tools', dur: 36 },
|
||||||
|
],
|
||||||
|
render: renderStack
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "leadership",
|
||||||
|
title: 'should demonstrate quality leadership',
|
||||||
|
tags: ["@leadership","@ci"],
|
||||||
|
duration: 220,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'mentor coverage stats', dur: 60 },
|
||||||
|
{ kind: "ok", title: 'shift-left adoption', dur: 90 },
|
||||||
|
],
|
||||||
|
render: renderLeadership
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "metrics",
|
||||||
|
title: 'should report quality KPIs',
|
||||||
|
tags: ["@ci","@perf"],
|
||||||
|
duration: 96,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'compute pass-rate', dur: 22 },
|
||||||
|
{ kind: "ok", title: 'compute coverage', dur: 48 },
|
||||||
|
],
|
||||||
|
render: renderMetrics
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resume",
|
||||||
|
title: 'should expose downloadable resume',
|
||||||
|
tags: ["@playwright"],
|
||||||
|
duration: 64,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'render resume', dur: 24 },
|
||||||
|
{ kind: "ok", title: 'assert download', dur: 18 },
|
||||||
|
],
|
||||||
|
render: renderResume
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
title: 'should accept inbound contact',
|
||||||
|
tags: ["@api"],
|
||||||
|
duration: 88,
|
||||||
|
steps: [
|
||||||
|
{ kind: "ok", title: 'expose email + linkedin', dur: 24 },
|
||||||
|
{ kind: "ok", title: 'assert reachable', dur: 40 },
|
||||||
|
],
|
||||||
|
render: renderContact
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
experience: [
|
||||||
|
{
|
||||||
|
company: "Niyasoft Canada Inc.",
|
||||||
|
role: "Senior QA Automation Engineer",
|
||||||
|
when: "Aug 2023 – Apr 2026",
|
||||||
|
where: "Vaughan, ON · remote · full-time",
|
||||||
|
bullets: [
|
||||||
|
"Built & maintained 300+ Playwright E2E tests and 250+ API/integration tests plus performance suites for a regulated online-casino platform; coverage spanned happy-path, negative, workflow, page-navigation, and network request/response checks across payments, wallet/cashier, game/lobby, and back-office flows — cutting manual regression effort by ~50%.",
|
||||||
|
"Stabilized the Playwright suite by replacing brittle waits with deterministic patterns and improving environment readiness, keeping daily pass rates consistently above ~95% across parallel CI stages.",
|
||||||
|
"Validated responsible-gaming end-to-end — deposit/loss/session limits, self-exclusion, cooling-off, reality checks — supporting compliance posture across licensed wagering markets.",
|
||||||
|
"Ran compliance-sensitive geo-eligibility scenarios with audit-friendly logging; traceability artifacts available for licensing reviews on demand.",
|
||||||
|
"Optimized GitHub Actions pipelines (regression, functional, component, smoke) with parallelized stages and daily PR cadence on a high-availability real-money stack.",
|
||||||
|
"Monitored GCP metrics and validated PostgreSQL-backed data integrity; prevented sev-1 incidents by catching performance regressions before release."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "RIOS Canada",
|
||||||
|
role: "Software Development Engineer in Test (SDET)",
|
||||||
|
when: "Jun 2022 – Jul 2023",
|
||||||
|
where: "Toronto, ON · remote · contract",
|
||||||
|
bullets: [
|
||||||
|
"Built Cypress E2E and API suites (Swagger/OpenAPI) from scratch across core product flows; enabled every-commit CI checks and cut manual regression time ~40% per release.",
|
||||||
|
"Introduced AODA/WCAG accessibility checks (alt text, keyboard nav, contrast) into Bitbucket CI gates, preventing accessibility regressions across web and mobile releases.",
|
||||||
|
"Automated test-environment provisioning with Ansible — disposable, repeatable setups that shortened spin-up and eliminated pre-regression drift.",
|
||||||
|
"Partnered with engineering and product on defect triage, risk-based prioritization, and pragmatic quality gates without blocking incremental delivery."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "Attabotics",
|
||||||
|
role: "QA Automation Developer",
|
||||||
|
when: "Sep 2021 – May 2022",
|
||||||
|
where: "Calgary, AB · remote · contract",
|
||||||
|
bullets: [
|
||||||
|
"Maintained 3,500+ SpecFlow/Gherkin scenarios with C# in .NET/Azure; owned flaky-test triage and kept daily build stability above ~90%.",
|
||||||
|
"Practiced left-shift QA in a large Agile team — co-authored scenarios with developers early in the sprint, tightening Given/When/Then clarity.",
|
||||||
|
"Stood up Docker-based local and CI-aligned test environments; used SQL Server for data setup, assertions, and traceability across integrated warehouse-automation workflows."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "Levkin Inc.",
|
||||||
|
role: "Senior Software Developer",
|
||||||
|
when: "Oct 2020 – Aug 2021",
|
||||||
|
where: "Vaughan, ON · remote · contract",
|
||||||
|
bullets: [
|
||||||
|
"Built reusable Playwright building blocks with deterministic patterns, eliminating arbitrary waits and measurably reducing suite flakiness.",
|
||||||
|
"Audited and refactored legacy test and UI code; documented testing strategy and shared patterns across the team.",
|
||||||
|
"Optimized GitLab CI/CD pipelines; piped test and pipeline metrics into Grafana dashboards for release visibility.",
|
||||||
|
"Provisioned AWS (S3) environments with Terraform, validated end-to-end, and promoted to dev via the team's release procedure.",
|
||||||
|
"Ongoing: self-hosted infrastructure lab and local-GPU AI assistant under the Levkin brand — see Projects."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "Accountants Templates Inc.",
|
||||||
|
role: "Senior Software Developer",
|
||||||
|
when: "Aug 2019 – Aug 2020",
|
||||||
|
where: "Calgary, AB · remote · contract",
|
||||||
|
bullets: [
|
||||||
|
"Owned CaseWare/CaseView template delivery including compliance updates, standards-driven releases, and documentation for internal and client use.",
|
||||||
|
"Reviewed software for improvements and implemented recommendations; collaborated with support on reported issues.",
|
||||||
|
"Automated build and packaging workflows, compressing ~8 hours of manual release effort to under 2 minutes per cycle."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "MNP LLP",
|
||||||
|
role: "Senior Application Developer 2",
|
||||||
|
when: "Aug 2017 – Jun 2019",
|
||||||
|
where: "Toronto, ON · remote · full-time",
|
||||||
|
bullets: [
|
||||||
|
"Developed and maintained C#, .NET, .NET Core applications integrating with CaseWare/CaseView; extended with JavaScript where specs required.",
|
||||||
|
"Contributed automation strategy and hands-on Selenium/Cucumber work; managed Jenkins triggers, Cucumber reporting, and Azure DevOps pipelines."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "CaseWare International Inc.",
|
||||||
|
role: "Software Developer",
|
||||||
|
when: "Aug 2006 – Jun 2017",
|
||||||
|
where: "Toronto, ON · hybrid · full-time",
|
||||||
|
bullets: [
|
||||||
|
"Delivered features, defect fixes, and client templates (JavaScript, HTML, YUI, jQuery, CSS) for global financial/audit systems; automated validation with SilkTest.",
|
||||||
|
"Mentored junior developers on conventions, debugging, and code review; built reusable JS libraries; Agile Scrum, Jira, Git."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "ROLI Consulting",
|
||||||
|
role: "Web/Application Developer",
|
||||||
|
when: "Jan 2001 – Jul 2012",
|
||||||
|
where: "Vaughan, ON · remote · part-time",
|
||||||
|
bullets: [
|
||||||
|
"Voice broadcasting and SMS service (Python, Twilio API); websites across multiple stacks; technical consulting for nonprofits and SMBs."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company: "Earlier Career — Kaboose · Coutts · EDS/Scotiabank",
|
||||||
|
role: "QA Automation · Java Developer · ETL Co-op",
|
||||||
|
when: "May 2005 – Aug 2006",
|
||||||
|
where: "Toronto, ON",
|
||||||
|
bullets: [
|
||||||
|
"QA automation with QTP/Quality Center, cross-browser and end-to-end testing, and UAT support (Kaboose); Java/J2EE development (Coutts); Informatica ETL co-op on AIX/DB2 (EDS/Scotiabank)."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
skills: [
|
||||||
|
{ name: "Test automation: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; POM, BDD", level: 96, tags: ["@playwright","@cypress","@selenium","@bdd","@api"] },
|
||||||
|
{ name: "Languages & frameworks: TypeScript, JavaScript, C#, .NET, Python, Java, Bash, Node.js", level: 92, tags: [] },
|
||||||
|
{ name: "CI/CD & DevOps: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker", level: 92, tags: ["@ci","@docker","@terraform"] },
|
||||||
|
{ name: "Cloud & infra: AWS, Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS", level: 84, tags: ["@cloud","@infra"] },
|
||||||
|
{ name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter", level: 86, tags: ["@perf"] },
|
||||||
|
{ name: "Data & domain: PostgreSQL, SQL Server, MySQL, DB2; CaseWare/CaseView, audit & financial software", level: 78, tags: [] },
|
||||||
|
{ name: "QA practices: BDD (SpecFlow, Cucumber), API testing (Postman, OpenAPI), accessibility (AODA/WCAG), risk-based testing, flaky-suite stabilization", level: 90, tags: ["@bdd","@a11y","@api"] },
|
||||||
|
{ name: "Leadership & collaboration: mentoring, test strategy & docs, partnering with product/engineering on release risk and pragmatic gates", level: 84, tags: ["@leadership"] },
|
||||||
|
{ name: "AI & LLM tooling: Cursor, local LLM, MCP servers, Copilot, Claude Code, Perplexity; GenAI-assisted test design", level: 82, tags: ["@ai"] },
|
||||||
|
{ name: "Tooling & workflows: code review, trunk-based, feature flags, canary releases, observability-driven debugging", level: 78, tags: ["@ci"] },
|
||||||
|
],
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "Levkin — Self-Hosted Infrastructure Lab",
|
||||||
|
tags: ["@infra","@docker","@ci"],
|
||||||
|
desc: "Proxmox-based homelab (VMs/LXC) running Gitea + CI runners, Vaultwarden, Vikunja, Uptime Kuma, Mailcow, Listmonk, n8n, SonarQube — provisioned via Ansible with Caddy edge TLS and TrueNAS backups. Full Linux admin: multi-domain DNS, firewall hardening, monitoring, repeatable deploys. Patterns directly informed production DevOps decisions in later roles."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Levkin — Privacy-First Local AI Assistant",
|
||||||
|
tags: ["@ai","@infra"],
|
||||||
|
desc: "Tool-using assistant wired into mail & calendars (triage, drafts, scheduling) on local GPU inference — prompts and context stay on-LAN, no SaaS LLMs. Composable with homelab identity, TLS, secrets, and automation so event-driven workflows hand off without third-party model APIs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Levkin — Playwright MCP Server",
|
||||||
|
tags: ["@ai","@playwright"],
|
||||||
|
desc: "MCP server developers use from Cursor and other MCP-capable assistants while writing Playwright tests — surfaces selectors, fixtures, and in-repo conventions so generated specs stay aligned with team patterns."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
stack: {
|
||||||
|
Editors: ["Cursor", "VS Code", "iTerm2"],
|
||||||
|
Languages: ["TypeScript","JavaScript","Python","C#","Bash"],
|
||||||
|
Testing: ["Playwright","Cypress","Selenium","Postman","k6","Artillery","JMeter"],
|
||||||
|
CI: ["GitHub Actions","GitLab","Jenkins","Azure DevOps","Bitbucket"],
|
||||||
|
Infra: ["Docker","Proxmox","Terraform","Ansible","Caddy","TrueNAS"],
|
||||||
|
AI: ["Cursor","Claude Code","Perplexity","Ollama (local)","MCP servers"]
|
||||||
|
},
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{ label: "Playwright E2E tests authored", value: "300+" },
|
||||||
|
{ label: "API / integration tests", value: "250+" },
|
||||||
|
{ label: "Parallel CI daily pass rate", value: "≈ 95%+" },
|
||||||
|
{ label: "Manual regression reduction", value: "≈ 50%" },
|
||||||
|
{ label: "SpecFlow scenarios maintained", value: "3,500+" },
|
||||||
|
{ label: "Years shipping software", value: "20+" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------- Section renderers ----------- */
|
||||||
|
function _esc(s){ return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
||||||
|
function _tags(arr){ return `<div class="card__tags">${arr.map(t=>`<span class="tag">${t}</span>`).join('')}</div>`; }
|
||||||
|
|
||||||
|
function renderAbout(){
|
||||||
|
const p = PORTFOLIO.person;
|
||||||
|
return `
|
||||||
|
<div class="block">
|
||||||
|
<p><strong>${p.title}</strong> based in ${p.location}.</p>
|
||||||
|
<p>${p.blurb}</p>
|
||||||
|
<p><em style="color:var(--accent);font-style:normal">▸</em> ${p.headline}</p>
|
||||||
|
<div class="snippet"><div class="ln">1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4</div><div class="code"><span class="cm">// portfolio.spec.ts</span>
|
||||||
|
<span class="kw">import</span> { test, expect } <span class="kw">from</span> <span class="str">'@playwright/test'</span>;
|
||||||
|
<span class="kw">test</span>(<span class="str">'ilia is a senior SDET'</span>, <span class="kw">async</span> ({ page }) => {
|
||||||
|
<span class="kw">await</span> expect(page.getByRole(<span class="str">'heading'</span>, { name: <span class="str">/ilia dobkin/i</span> })).toBeVisible();
|
||||||
|
});</div></div>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="mailto:${p.email}">contact()</a>
|
||||||
|
<a class="cta cta--ghost" href="${p.linkedin}" target="_blank" rel="noopener">linkedin.profile</a>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExperience(){
|
||||||
|
return `<div class="block">${
|
||||||
|
PORTFOLIO.experience.map((e,i)=>`
|
||||||
|
<h4>${_esc(e.company)} <span style="color:var(--text-4);font-weight:400">— ${_esc(e.role)}</span></h4>
|
||||||
|
<p style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-3);margin:2px 0 6px">${_esc(e.when)} · ${_esc(e.where)}</p>
|
||||||
|
<ul>${e.bullets.map(b=>`<li>${_esc(b)}</li>`).join('')}</ul>
|
||||||
|
`).join('')
|
||||||
|
}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkills(){
|
||||||
|
return `<div class="block"><div class="skills">${
|
||||||
|
PORTFOLIO.skills.map(s=>`
|
||||||
|
<div class="skill">
|
||||||
|
<div>
|
||||||
|
<div class="skill__name">${s.name.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')}</div>
|
||||||
|
${s.tags.length?`<div class="card__tags" style="margin-top:6px">${s.tags.map(t=>`<span class="tag">${t}</span>`).join('')}</div>`:''}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="skill__bar"><div class="skill__fill" data-pct="${s.level}"></div></div>
|
||||||
|
<div class="skill__pct">${s.level}%</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')
|
||||||
|
}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects(){
|
||||||
|
return `<div class="block"><div class="cards">${
|
||||||
|
PORTFOLIO.projects.map(p=>`
|
||||||
|
<div class="card">
|
||||||
|
<h5>${_esc(p.name)}</h5>
|
||||||
|
<p>${_esc(p.desc)}</p>
|
||||||
|
${_tags(p.tags)}
|
||||||
|
</div>`).join('')
|
||||||
|
}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStack(){
|
||||||
|
return `<div class="block">${
|
||||||
|
Object.entries(PORTFOLIO.stack).map(([k,v])=>`
|
||||||
|
<h4>${k}</h4>
|
||||||
|
<div class="card__tags">${v.map(x=>`<span class="tag">${x}</span>`).join('')}</div>
|
||||||
|
`).join('')
|
||||||
|
}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeadership(){
|
||||||
|
return `<div class="block">
|
||||||
|
<h4>Mentoring & strategy</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Mentored junior developers and QA engineers across CaseWare, MNP, and Niyasoft — conventions, debugging, code review.</li>
|
||||||
|
<li>Authored test strategy docs and reusable patterns adopted team-wide.</li>
|
||||||
|
<li>Partner with product/engineering on release risk and pragmatic quality gates that don't block delivery.</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Shift-left in practice</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Co-author scenarios with developers early in the sprint, tightening Given/When/Then clarity before code is merged.</li>
|
||||||
|
<li>Wire accessibility and contract checks into PR gates so quality lives left of the pipeline, not at the end.</li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(){
|
||||||
|
return `<div class="block"><div class="cards">${
|
||||||
|
PORTFOLIO.metrics.map(m=>`
|
||||||
|
<div class="card">
|
||||||
|
<h5 style="font-size:28px;color:var(--accent);margin-bottom:6px">${_esc(m.value)}</h5>
|
||||||
|
<p style="color:var(--text-3)">${_esc(m.label)}</p>
|
||||||
|
</div>`).join('')
|
||||||
|
}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResume(){
|
||||||
|
return `<div class="block">
|
||||||
|
<p>Full PDF resume — generated from this same source of truth.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<button class="cta" id="dl-resume">⇩ download resume.pdf</button>
|
||||||
|
<button class="cta cta--ghost" id="print-resume">print()</button>
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-4);font-size:11.5px;font-family:var(--font-mono);margin-top:10px">// resume renders inline using the print stylesheet — try ⌘P</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContact(){
|
||||||
|
const p = PORTFOLIO.person;
|
||||||
|
return `<div class="block"><div class="contact-grid">
|
||||||
|
<div class="contact-cell"><label>email</label><a href="mailto:${p.email}">${p.email}</a></div>
|
||||||
|
<div class="contact-cell"><label>phone</label>${p.phone}</div>
|
||||||
|
<div class="contact-cell"><label>linkedin</label><a href="${p.linkedin}" target="_blank" rel="noopener">in/idobkin</a></div>
|
||||||
|
<div class="contact-cell"><label>gitea (self-hosted)</label><a href="${p.gitea}" target="_blank" rel="noopener">git.levkin.ca</a></div>
|
||||||
|
<div class="contact-cell"><label>site</label><a href="${p.site}" target="_blank" rel="noopener">iliadobkin.com</a></div>
|
||||||
|
<div class="contact-cell"><label>location</label>${p.location}</div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user