From 8894748179c7b53aad1453016826d1f01049479d Mon Sep 17 00:00:00 2001 From: Builder Date: Sun, 10 May 2026 23:57:19 +0000 Subject: [PATCH] Portfolio v1: Playwright runner UI --- assets/favicon.svg | 6 + css/app.css | 455 ++++++++++++++++++++++++++++++++++++++++++ css/base.css | 113 +++++++++++ index.html | 152 +++++++++++++++ js/app.js | 477 +++++++++++++++++++++++++++++++++++++++++++++ js/data.js | 404 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 1607 insertions(+) create mode 100644 assets/favicon.svg create mode 100644 css/app.css create mode 100644 css/base.css create mode 100644 index.html create mode 100644 js/app.js create mode 100644 js/data.js diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..6c64116 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/css/app.css b/css/app.css new file mode 100644 index 0000000..1eaf589 --- /dev/null +++ b/css/app.css @@ -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; } +} diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..a080208 --- /dev/null +++ b/css/base.css @@ -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); } diff --git a/index.html b/index.html new file mode 100644 index 0000000..ae87c16 --- /dev/null +++ b/index.html @@ -0,0 +1,152 @@ + + + + + + Ilia Dobkin — portfolio.spec.ts + + + + + + + + + + + + + +
+ + + + + +
+
+ + + + +
+ portfolio.spec.ts · 9 tests +
+ + +
+
+
describe
+

Ilia Dobkin

+

Senior SDET · Toronto, ON · test.describe("portfolio")

+

Click the green next to any test to run it — or press Run all above.

+
+
+
+ + +
+
+
Career timeline · trace viewer
+
pass milestone
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+ + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..b119ff3 --- /dev/null +++ b/js/app.js @@ -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: '', + check: '', + dot: '', + spin: '', + chev: '', + rerun: '', + }; + 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 `test('${t.title}')`; + } + + // ============ SIDEBAR (tree) ============ + function renderTree(){ + const wrap = $('#tree'); + wrap.innerHTML = ` +
+
+ ${ICONS.chev} + describe('${data.suite.name}') +
+ ${tests.map(renderTreeTest).join('')} +
`; + + 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 ` +
+ + ${statusIcon(s.status)} + ${titleHTML(t)} + ${s.runtime?formatMs(s.runtime):''} +
${t.tags.map(x=>`${x}`).join('')}
+
`; + } + 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=>`${t}`).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 ` +
+
+ ${ICONS.chev} + ${statusIcon(s.status)} + ${titleHTML(t)} + ${s.runtime?formatMs(s.runtime):''} + +
+
+
+
`; + }).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=>` +
+ ${st.kind==='info'?ICONS.dot:ICONS.check} + ${st.title} + ${formatMs(st.dur)} +
`).join(''); + body.innerHTML = `
${stepsHTML}
${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 = `${ts}${lvl.toUpperCase()}${msg}`; + 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 ` +
+
${_esc(it.company)} · ${_esc(it.role)}
+
+
+
+
${months}mo
+
`; + }).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 = `
${years.map(y=>`${y}`).join('')}
`; + } + 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 = [ + ['// portfolio.spec.ts — Senior SDET portfolio, expressed as a Playwright test suite'], + ['import { test, expect } from \'@playwright/test\';'], + ['import { person, experience, skills, projects } from \'./fixtures/ilia\';'], + [''], + [`test.describe('${data.suite.name}', () => {`], + [''], + ]; + tests.forEach(t=>{ + const tagStr = t.tags.length ? ` // ${t.tags.join(' ')}` : ''; + lines.push([` test('${t.title}', async ({ page }) => {${tagStr}`]); + t.steps.forEach(s=>{ + lines.push([` await test.step('${s.title.replace(/'/g,"\\'")}', async () => { /* ${s.dur}ms */ });`]); + }); + lines.push([` });`]); + lines.push(['']); + }); + lines.push(['});']); + + $('#source').innerHTML = lines.map((row,i)=>` +
+ ${i+1} + ${row[0]} +
`).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 = `${p.first} ${p.last} — Resume + +

${p.first} ${p.last}

${p.email} · ${p.phone}
+
${p.title} · ${p.location} · ${p.linkedin}
+

${p.blurb}

+

Experience

+${data.experience.map(e=>` +

${e.company} — ${e.role}

+
${e.when} · ${e.where}
+ +`).join('')} +

Skills

+ +

Projects

+${data.projects.map(p=>`

${p.name}

${p.desc}

`).join('')} + +`; + 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); +})(); diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..3fbdcf1 --- /dev/null +++ b/js/data.js @@ -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 `
${arr.map(t=>`${t}`).join('')}
`; } + +function renderAbout(){ + const p = PORTFOLIO.person; + return ` +
+

${p.title} based in ${p.location}.

+

${p.blurb}

+

${p.headline}

+
1 +2 +3 +4
// portfolio.spec.ts +import { test, expect } from '@playwright/test'; +test('ilia is a senior SDET', async ({ page }) => { + await expect(page.getByRole('heading', { name: /ilia dobkin/i })).toBeVisible(); +});
+ +
`; +} + +function renderExperience(){ + return `
${ + PORTFOLIO.experience.map((e,i)=>` +

${_esc(e.company)} — ${_esc(e.role)}

+

${_esc(e.when)} · ${_esc(e.where)}

+ + `).join('') + }
`; +} + +function renderSkills(){ + return `
${ + PORTFOLIO.skills.map(s=>` +
+
+
${s.name.replace(/\*\*(.+?)\*\*/g,'$1')}
+ ${s.tags.length?`
${s.tags.map(t=>`${t}`).join('')}
`:''} +
+
+
+
${s.level}%
+
+
`).join('') + }
`; +} + +function renderProjects(){ + return `
${ + PORTFOLIO.projects.map(p=>` +
+
${_esc(p.name)}
+

${_esc(p.desc)}

+ ${_tags(p.tags)} +
`).join('') + }
`; +} + +function renderStack(){ + return `
${ + Object.entries(PORTFOLIO.stack).map(([k,v])=>` +

${k}

+
${v.map(x=>`${x}`).join('')}
+ `).join('') + }
`; +} + +function renderLeadership(){ + return `
+

Mentoring & strategy

+ +

Shift-left in practice

+ +
`; +} + +function renderMetrics(){ + return `
${ + PORTFOLIO.metrics.map(m=>` +
+
${_esc(m.value)}
+

${_esc(m.label)}

+
`).join('') + }
`; +} + +function renderResume(){ + return `
+

Full PDF resume — generated from this same source of truth.

+
+ + +
+

// resume renders inline using the print stylesheet — try ⌘P

+
`; +} + +function renderContact(){ + const p = PORTFOLIO.person; + return `
+ +
${p.phone}
+
in/idobkin
+
git.levkin.ca
+ +
${p.location}
+
`; +}