Compare commits
No commits in common. "master" and "feature/professional-homepage" have entirely different histories.
master
...
feature/pr
@ -43,7 +43,6 @@ def request_raw(
|
||||
*,
|
||||
payload: Any | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
timeout_sec: float | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Send one authenticated request to Ariadne and return the raw response.
|
||||
|
||||
@ -56,10 +55,9 @@ def request_raw(
|
||||
|
||||
url = _url(path)
|
||||
attempts = max(1, settings.ARIADNE_RETRY_COUNT)
|
||||
request_timeout = timeout_sec if timeout_sec is not None else settings.ARIADNE_TIMEOUT_SEC
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with httpx.Client(timeout=request_timeout) as client:
|
||||
with httpx.Client(timeout=settings.ARIADNE_TIMEOUT_SEC) as client:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
@ -80,7 +78,7 @@ def request_raw(
|
||||
"method": method,
|
||||
"path": path,
|
||||
"attempt": attempt,
|
||||
"timeout_sec": request_timeout,
|
||||
"timeout_sec": settings.ARIADNE_TIMEOUT_SEC,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
@ -95,7 +93,6 @@ def proxy(
|
||||
*,
|
||||
payload: Any | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
timeout_sec: float | None = None,
|
||||
) -> tuple[Any, int]:
|
||||
"""Proxy an Ariadne response through Flask as JSON plus status code.
|
||||
|
||||
@ -104,7 +101,7 @@ def proxy(
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = request_raw(method, path, payload=payload, params=params, timeout_sec=timeout_sec)
|
||||
resp = request_raw(method, path, payload=payload, params=params)
|
||||
except AriadneError as exc:
|
||||
return jsonify({"error": str(exc)}), exc.status_code
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from .. import ariadne_client, settings
|
||||
from .. import ariadne_client
|
||||
from ..keycloak import require_auth, require_account_access
|
||||
|
||||
|
||||
@ -142,12 +142,7 @@ def register_account_wolf(app) -> None:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy(
|
||||
"POST",
|
||||
"/api/admin/game-mode/start",
|
||||
payload=_json_payload(),
|
||||
timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC,
|
||||
)
|
||||
return ariadne_client.proxy("POST", "/api/admin/game-mode/start", payload=_json_payload())
|
||||
|
||||
@app.route("/api/account/wolf/game-mode/stop", methods=["POST"])
|
||||
@require_auth
|
||||
@ -155,12 +150,7 @@ def register_account_wolf(app) -> None:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy(
|
||||
"POST",
|
||||
"/api/admin/game-mode/stop",
|
||||
payload=_json_payload(),
|
||||
timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC,
|
||||
)
|
||||
return ariadne_client.proxy("POST", "/api/admin/game-mode/stop", payload=_json_payload())
|
||||
|
||||
@app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"])
|
||||
@require_auth
|
||||
|
||||
@ -22,18 +22,18 @@ def register(app) -> None:
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
user_message = (payload.get("message") or "").strip()
|
||||
profile = (payload.get("profile") or payload.get("mode") or "atlas-smart").strip().lower()
|
||||
profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower()
|
||||
conversation_id = payload.get("conversation_id") if isinstance(payload.get("conversation_id"), str) else ""
|
||||
|
||||
if not user_message:
|
||||
return jsonify({"error": "message required"}), 400
|
||||
|
||||
started = time.time()
|
||||
mode = "smart"
|
||||
if profile in {"atlas-genius", "genius"}:
|
||||
mode = "genius"
|
||||
elif profile in {"atlas-quick", "quick"}:
|
||||
mode = "quick"
|
||||
if profile in {"atlas-smart", "smart"}:
|
||||
mode = "smart"
|
||||
elif profile in {"atlas-genius", "genius"}:
|
||||
mode = "genius"
|
||||
reply = _atlasbot_answer(user_message, mode, conversation_id)
|
||||
source = f"atlas-{mode}"
|
||||
if reply:
|
||||
@ -67,7 +67,7 @@ def register(app) -> None:
|
||||
def ai_info() -> Any:
|
||||
"""Return model and placement metadata for the requested AI profile."""
|
||||
|
||||
profile = (request.args.get("profile") or "atlas-smart").strip().lower()
|
||||
profile = (request.args.get("profile") or "atlas-quick").strip().lower()
|
||||
meta = _discover_ai_meta(profile)
|
||||
return jsonify(meta)
|
||||
|
||||
|
||||
@ -14,15 +14,6 @@ from .. import settings
|
||||
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
|
||||
|
||||
|
||||
def _status_response(payload: dict[str, Any]) -> Any:
|
||||
"""Return lab status JSON with browser and proxy caching disabled."""
|
||||
|
||||
response = jsonify(payload)
|
||||
response.headers["Cache-Control"] = "no-store, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _vm_query(expr: str) -> float | None:
|
||||
"""Run one instant VictoriaMetrics query and return the largest value."""
|
||||
|
||||
@ -180,7 +171,7 @@ def register(app) -> None:
|
||||
now = time.time()
|
||||
cached = _LAB_STATUS_CACHE.get("value")
|
||||
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):
|
||||
return _status_response(cached)
|
||||
return jsonify(cached)
|
||||
|
||||
t_total = time.perf_counter()
|
||||
timings_ms: dict[str, int] = {}
|
||||
@ -251,4 +242,4 @@ def register(app) -> None:
|
||||
|
||||
_LAB_STATUS_CACHE["ts"] = now
|
||||
_LAB_STATUS_CACHE["value"] = payload
|
||||
return _status_response(payload)
|
||||
return jsonify(payload)
|
||||
|
||||
@ -80,7 +80,6 @@ KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
|
||||
|
||||
ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip()
|
||||
ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10"))
|
||||
ARIADNE_GAME_MODE_TIMEOUT_SEC = float(os.getenv("ARIADNE_GAME_MODE_TIMEOUT_SEC", "900"))
|
||||
ARIADNE_RETRY_COUNT = int(os.getenv("ARIADNE_RETRY_COUNT", "2"))
|
||||
ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2"))
|
||||
|
||||
|
||||
@ -8,20 +8,13 @@ from atlas_portal.routes import account_wolf
|
||||
class DummyAriadne:
|
||||
def __init__(self, enabled: bool = True) -> None:
|
||||
self._enabled = enabled
|
||||
self.calls: list[tuple[str, str, object | None, dict | None, float | None]] = []
|
||||
self.calls: list[tuple[str, str, object | None, dict | None]] = []
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def proxy(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: object | None = None,
|
||||
params: dict | None = None,
|
||||
timeout_sec: float | None = None,
|
||||
):
|
||||
self.calls.append((method, path, payload, params, timeout_sec))
|
||||
def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None):
|
||||
self.calls.append((method, path, payload, params))
|
||||
return jsonify({"path": path, "payload": payload, "params": params})
|
||||
|
||||
|
||||
@ -49,7 +42,7 @@ def test_wolf_status_proxies_source_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"}, None)]
|
||||
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})]
|
||||
|
||||
|
||||
def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None:
|
||||
@ -61,7 +54,7 @@ def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"}, None)]
|
||||
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"})]
|
||||
|
||||
|
||||
def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
|
||||
@ -75,7 +68,7 @@ def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None, None)]
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)]
|
||||
|
||||
|
||||
def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None:
|
||||
@ -88,7 +81,7 @@ def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)]
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)]
|
||||
|
||||
|
||||
def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None:
|
||||
@ -101,7 +94,7 @@ def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None, None)]
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None)]
|
||||
|
||||
|
||||
def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
|
||||
@ -115,7 +108,7 @@ def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)]
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)]
|
||||
|
||||
|
||||
def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
|
||||
@ -132,11 +125,11 @@ def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
|
||||
client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"})
|
||||
|
||||
assert ariadne.calls == [
|
||||
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}, None),
|
||||
("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None, None),
|
||||
("POST", "/api/admin/game-mode/start", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC),
|
||||
("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC),
|
||||
("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None, None),
|
||||
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}),
|
||||
("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None),
|
||||
("POST", "/api/admin/game-mode/start", {"game": "steam"}, None),
|
||||
("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None),
|
||||
("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None),
|
||||
]
|
||||
|
||||
|
||||
@ -150,7 +143,7 @@ def test_wolf_user_revoke_accepts_public_payload_ip(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None, None)]
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None)]
|
||||
|
||||
|
||||
def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None:
|
||||
|
||||
@ -45,28 +45,12 @@ class AiRouteTests(TestCase):
|
||||
self.assertEqual(data.get("source"), f"atlas-{expected_mode}")
|
||||
self.assertEqual(data.get("reply"), f"{expected_mode}:conv-{profile}")
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/chat",
|
||||
data=json.dumps(
|
||||
{
|
||||
"message": "How is Titan doing?",
|
||||
"conversation_id": "conv-default",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
data = resp.get_json()
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data.get("source"), "atlas-smart")
|
||||
self.assertEqual(data.get("reply"), "smart:conv-default")
|
||||
|
||||
self.assertEqual(
|
||||
seen,
|
||||
[
|
||||
("quick", "conv-atlas-quick"),
|
||||
("smart", "conv-atlas-smart"),
|
||||
("genius", "conv-atlas-genius"),
|
||||
("smart", "conv-default"),
|
||||
],
|
||||
)
|
||||
|
||||
@ -79,14 +63,6 @@ class AiRouteTests(TestCase):
|
||||
self.assertEqual(data.get("profile"), "atlas-genius")
|
||||
self.assertEqual(data.get("model"), "genius-model")
|
||||
|
||||
with mock.patch.object(ai.settings, "AI_ATLASBOT_MODEL_SMART", "smart-model"):
|
||||
resp = self.client.get("/api/ai/info")
|
||||
|
||||
data = resp.get_json()
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data.get("profile"), "atlas-smart")
|
||||
self.assertEqual(data.get("model"), "smart-model")
|
||||
|
||||
def test_atlasbot_answer_uses_profile_specific_timeout(self):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
|
||||
@ -129,8 +129,6 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None:
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Cache-Control"] == "no-store, max-age=0"
|
||||
assert response.headers["Pragma"] == "no-cache"
|
||||
assert payload["connected"] is True
|
||||
assert payload["atlas"]["source"] == "grafana"
|
||||
assert payload["dedicated_hosts"]["up"] is True
|
||||
@ -146,7 +144,6 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None:
|
||||
|
||||
second = client.get("/api/lab/status")
|
||||
assert second.get_json() == payload
|
||||
assert second.headers["Cache-Control"] == "no-store, max-age=0"
|
||||
|
||||
lab._LAB_STATUS_CACHE["ts"] = 0.0
|
||||
lab._LAB_STATUS_CACHE["value"] = None
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "Brad Stein",
|
||||
"jobTitle": "Senior SDET / DevOps Automation Engineer",
|
||||
"jobTitle": "DevOps Automation Engineer / Senior SDET",
|
||||
"url": "https://bstein.dev/",
|
||||
"email": "mailto:brad@bstein.dev",
|
||||
"sameAs": ["https://www.linkedin.com/in/steinbradley/", "https://scm.bstein.dev/bstein"]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Brad Stein, Senior SDET and DevOps Automation Engineer</title>
|
||||
<title id="title">Brad Stein, DevOps Automation Engineer and Senior SDET</title>
|
||||
<desc id="desc">A dark bstein.dev social card with professional engineering positioning and Titan Lab platform proof.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
@ -20,7 +20,7 @@
|
||||
<circle cx="880" cy="174" r="6" fill="#00e5c5"/>
|
||||
<text x="80" y="196" fill="#00e5c5" font-family="JetBrains Mono, monospace" font-size="26" letter-spacing="3">BSTEIN.DEV</text>
|
||||
<text x="80" y="286" fill="#f7fbff" font-family="Space Grotesk, Inter, sans-serif" font-size="74" font-weight="700">Brad Stein</text>
|
||||
<text x="80" y="352" fill="#d7e5ff" font-family="Space Grotesk, Inter, sans-serif" font-size="40" font-weight="600">Senior SDET / DevOps Automation Engineer</text>
|
||||
<text x="80" y="352" fill="#d7e5ff" font-family="Space Grotesk, Inter, sans-serif" font-size="40" font-weight="600">DevOps Automation Engineer / Senior SDET</text>
|
||||
<text x="80" y="422" fill="#9fb2d0" font-family="Space Grotesk, Inter, sans-serif" font-size="30">CI/CD · Kubernetes · Python · Linux · Observability</text>
|
||||
<text x="80" y="482" fill="#9fb2d0" font-family="Space Grotesk, Inter, sans-serif" font-size="26">Titan Lab is the live platform proof behind the work.</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@ -37,8 +37,7 @@ async function refreshLabStatus() {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/status?ts=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
const resp = await fetch("/api/lab/status", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
@ -46,14 +45,12 @@ async function refreshLabStatus() {
|
||||
labStatus.value = await resp.json();
|
||||
statusError.value = "";
|
||||
} catch (err) {
|
||||
if (!labStatus.value) {
|
||||
labStatus.value = null;
|
||||
if (err?.name === "AbortError") {
|
||||
statusError.value = "Live data timed out";
|
||||
} else {
|
||||
statusError.value = "Live data unavailable";
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
statusLoading.value = false;
|
||||
@ -72,7 +69,7 @@ onUnmounted(() => {
|
||||
|
||||
function scheduleNextPoll() {
|
||||
if (pollTimerId) window.clearTimeout(pollTimerId);
|
||||
const delayMs = labStatus.value ? 30000 : 4000;
|
||||
const delayMs = labStatus.value ? 30000 : 8000;
|
||||
pollTimerId = window.setTimeout(refreshLabStatus, delayMs);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -47,13 +47,8 @@ export function useWolfDashboard() {
|
||||
sourceIp: "",
|
||||
unlockTtlSeconds: 28800,
|
||||
gpuPriority: "unknown",
|
||||
gpuOwner: "unknown",
|
||||
gameModeStatus: "unknown",
|
||||
gameModeActive: false,
|
||||
localModel: "unknown",
|
||||
localModelLoaded: false,
|
||||
localInferenceReady: false,
|
||||
inferencePath: "fallback",
|
||||
selectedGame: "steam",
|
||||
note: "",
|
||||
manualIp: "",
|
||||
@ -87,14 +82,8 @@ export function useWolfDashboard() {
|
||||
wolf.sourceIp = data.moonlight?.source_ip || "";
|
||||
wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800);
|
||||
wolf.gpuPriority = data.gpu?.priority || "unknown";
|
||||
const gameMode = data.gpu?.game_mode || {};
|
||||
wolf.gpuOwner = gameMode.gpu_owner || "unknown";
|
||||
wolf.gameModeStatus = gameMode.status || "unknown";
|
||||
wolf.gameModeActive = Boolean(gameMode.active);
|
||||
wolf.localModel = gameMode.model || "unknown";
|
||||
wolf.localModelLoaded = Boolean(gameMode.model_loaded);
|
||||
wolf.localInferenceReady = Boolean(gameMode.local_inference_ready);
|
||||
wolf.inferencePath = gameMode.inference_path || "fallback";
|
||||
wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown";
|
||||
wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active);
|
||||
wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : [];
|
||||
wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : [];
|
||||
wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : [];
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
<template>
|
||||
<section id="contact" class="conversion-section" aria-labelledby="contact-title">
|
||||
<div class="conversion-card final-contact">
|
||||
<div>
|
||||
<p class="section-kicker mono">Contact</p>
|
||||
<h2 id="contact-title">{{ content.contactSection.heading }}</h2>
|
||||
<p>{{ content.contactSection.description }}</p>
|
||||
<p>
|
||||
Recruiting for a remote engineering role? Send the role description, contract terms, location restrictions, and
|
||||
expected timeline.
|
||||
</p>
|
||||
</div>
|
||||
<div class="contact-actions action-card action-stack" aria-label="Contact actions">
|
||||
<a class="btn primary-action" :href="mailtoHref">Email Brad</a>
|
||||
<a class="btn secondary" :href="content.linkedInUrl" target="_blank" rel="noopener noreferrer">
|
||||
Connect on LinkedIn
|
||||
</a>
|
||||
<button class="btn tertiary button-like" type="button" @click="copyEmail">
|
||||
{{ emailCopied ? "Email Copied" : "Copy Email Address" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div>
|
||||
<div class="footer-name">{{ content.professionalName }}</div>
|
||||
<div>{{ content.role }}</div>
|
||||
</div>
|
||||
<nav aria-label="Footer navigation">
|
||||
<a :href="mailtoHref">Email</a>
|
||||
<a :href="content.linkedInUrl" target="_blank" rel="noopener noreferrer">LinkedIn</a>
|
||||
<RouterLink :to="content.resumeUrl">Résumé</RouterLink>
|
||||
<a :href="content.sourceUrl" target="_blank" rel="noopener noreferrer">Public Source</a>
|
||||
<a href="#platform">Platform</a>
|
||||
<button type="button" @click="doLogin">Sign In</button>
|
||||
<RouterLink to="/request-access">Request Lab Access</RouterLink>
|
||||
</nav>
|
||||
<div class="copyright mono">© {{ currentYear }} Brad Stein</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { login } from "../auth";
|
||||
|
||||
const props = defineProps({
|
||||
content: Object,
|
||||
mailtoHref: String,
|
||||
currentYear: Number,
|
||||
});
|
||||
|
||||
const emailCopied = ref(false);
|
||||
|
||||
function doLogin() {
|
||||
login();
|
||||
}
|
||||
|
||||
async function copyEmail() {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(props.content.primaryContact.email);
|
||||
emailCopied.value = true;
|
||||
window.setTimeout(() => {
|
||||
emailCopied.value = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
emailCopied.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversion-section {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
scroll-margin-top: 0;
|
||||
margin-top: 0;
|
||||
padding: clamp(54px, 7vh, 82px) 0;
|
||||
}
|
||||
|
||||
.conversion-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: clamp(28px, 4vw, 42px);
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.conversion-card h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 46px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.conversion-card p {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.final-contact {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, rgba(0, 229, 197, 0.08), rgba(120, 180, 255, 0.06));
|
||||
padding: clamp(26px, 3.5vw, 38px);
|
||||
}
|
||||
|
||||
#contact {
|
||||
padding-top: clamp(42px, 6vh, 72px);
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(5, 12, 28, 0.55);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
justify-self: end;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-stack .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: linear-gradient(135deg, rgba(0, 229, 197, 0.9), rgba(120, 180, 255, 0.82));
|
||||
color: #02141d;
|
||||
}
|
||||
|
||||
.tertiary {
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.button-like {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 56px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-name {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.site-footer nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-footer button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
.site-footer a:focus-visible,
|
||||
.site-footer button:focus-visible {
|
||||
outline: 2px solid rgba(0, 229, 197, 0.75);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.conversion-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.conversion-section {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
justify-self: stretch;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.conversion-section {
|
||||
margin-top: 0;
|
||||
padding: 50px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,351 +0,0 @@
|
||||
<template>
|
||||
<section class="hero-section" aria-labelledby="home-title">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow mono">{{ content.eyebrow }}</p>
|
||||
<h1 id="home-title">{{ content.professionalName }}</h1>
|
||||
<h2>{{ content.role }}</h2>
|
||||
<p class="hero-summary">{{ content.summary }}</p>
|
||||
<p class="experience-line">{{ content.experienceLine }}</p>
|
||||
<p class="availability-line">{{ content.availability.label }}</p>
|
||||
|
||||
<div class="cta-row" aria-label="Professional actions">
|
||||
<a class="btn primary-action" :href="mailtoHref">Discuss a Project</a>
|
||||
<RouterLink class="btn secondary" :to="content.resumeUrl">View Résumé</RouterLink>
|
||||
<a class="btn tertiary" href="#platform">Explore the Live Platform</a>
|
||||
</div>
|
||||
|
||||
<div class="tech-list" aria-label="Core technologies">
|
||||
<span v-for="tech in content.techStack" :key="tech" class="pill mono">{{ tech }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="status-card glass" aria-labelledby="status-title">
|
||||
<div class="status-head">
|
||||
<p class="eyebrow mono">Live platform</p>
|
||||
<h3 id="status-title">Titan Lab Status</h3>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div v-for="item in statusItems" :key="item.label" class="status-row">
|
||||
<span class="status-dot" :class="item.state" aria-hidden="true"></span>
|
||||
<div class="status-row-copy">
|
||||
<a v-if="item.labelHref" class="status-label status-title-link" :href="item.labelHref">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<div v-else class="status-label">{{ item.label }}</div>
|
||||
<a
|
||||
v-if="item.href && isExternal(item.href)"
|
||||
class="status-value mono status-link"
|
||||
:href="item.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ item.value }}
|
||||
</a>
|
||||
<RouterLink v-else-if="item.href" class="status-value mono status-link" :to="item.href">
|
||||
{{ item.value }}
|
||||
</RouterLink>
|
||||
<div v-else class="status-value mono">{{ item.value }}</div>
|
||||
<div v-if="item.children?.length" class="status-children" :aria-label="`${item.label} details`">
|
||||
<div v-for="child in item.children" :key="child.label" class="status-child">
|
||||
<span class="status-child-dot" :class="child.state" aria-hidden="true"></span>
|
||||
<span class="status-child-label">{{ child.label }}</span>
|
||||
<span class="status-child-value mono">{{ child.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="status-note mono">Last checked: {{ lastCheckedLabel }}</p>
|
||||
<p v-if="error || (!loading && !labStatus?.connected)" class="status-note">
|
||||
Live status is temporarily unavailable. The platform overview remains available below.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
defineProps({
|
||||
content: Object,
|
||||
statusItems: Array,
|
||||
labStatus: Object,
|
||||
loading: Boolean,
|
||||
error: String,
|
||||
lastCheckedLabel: String,
|
||||
mailtoHref: String,
|
||||
});
|
||||
|
||||
function isExternal(href) {
|
||||
return typeof href === "string" && /^https?:\/\//.test(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-section {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.75fr) minmax(320px, 0.85fr);
|
||||
gap: 26px;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 86px);
|
||||
padding: 42px 0 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section::before,
|
||||
.hero-section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
inset: 16% -8vw 8% 50%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(ellipse at 68% 42%, rgba(0, 229, 197, 0.17), transparent 32%),
|
||||
radial-gradient(ellipse at 28% 66%, rgba(120, 180, 255, 0.11), transparent 36%),
|
||||
radial-gradient(ellipse at center, rgba(11, 24, 46, 0.58), transparent 72%);
|
||||
opacity: 0.8;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.hero-section::after {
|
||||
width: min(300px, 24vw);
|
||||
top: 28%;
|
||||
right: 6vw;
|
||||
bottom: 22%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 229, 197, 0.16), transparent 64%),
|
||||
linear-gradient(90deg, transparent, rgba(120, 180, 255, 0.08), transparent);
|
||||
opacity: 0.32;
|
||||
filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.status-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: clamp(44px, 7vw, 72px);
|
||||
line-height: 0.98;
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
font-size: clamp(26px, 3.2vw, 36px);
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
max-width: 760px;
|
||||
margin: 20px 0 0;
|
||||
font-size: clamp(18px, 2.2vw, 22px);
|
||||
line-height: 1.55;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.experience-line,
|
||||
.availability-line {
|
||||
max-width: 760px;
|
||||
margin: 14px 0 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.availability-line {
|
||||
color: rgba(170, 255, 215, 0.94);
|
||||
}
|
||||
|
||||
.tech-list,
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tech-list {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: linear-gradient(135deg, rgba(0, 229, 197, 0.9), rgba(120, 180, 255, 0.82));
|
||||
color: #02141d;
|
||||
}
|
||||
|
||||
.tertiary {
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-head h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
margin-top: 4px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.ok {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-dot.warn {
|
||||
background: #f2c94c;
|
||||
}
|
||||
|
||||
.status-dot.bad {
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.status-row-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 800;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.status-title-link {
|
||||
display: inline-flex;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status-title-link:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-value,
|
||||
.status-note {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-link {
|
||||
display: inline-flex;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-children {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-child {
|
||||
display: grid;
|
||||
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-child-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-child-dot.ok {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-child-dot.warn {
|
||||
background: #f2c94c;
|
||||
}
|
||||
|
||||
.status-child-dot.bad {
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.status-child-value {
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
.hero-copy h2 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.hero-section {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
inset: 96px -22vw 24px 18%;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.hero-section::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hero-section {
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
inset: 84px -44vw 18px 8%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,391 +0,0 @@
|
||||
<template>
|
||||
<section id="member-access" class="conversion-section" aria-labelledby="member-title">
|
||||
<div class="conversion-card member-panel glass">
|
||||
<div class="member-copy">
|
||||
<p class="section-kicker mono">Member platform</p>
|
||||
<h2 id="member-title">{{ content.memberAccessCopy.heading }}</h2>
|
||||
<p>{{ content.memberAccessCopy.description }}</p>
|
||||
<p v-if="memberPanelCopy" class="member-state-copy">{{ memberPanelCopy }}</p>
|
||||
</div>
|
||||
|
||||
<div class="member-actions action-card" aria-live="polite" aria-label="Titan Lab member actions">
|
||||
<div v-if="!auth.ready" class="member-loading">
|
||||
<span class="skeleton"></span>
|
||||
<span>Checking session...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="auth.authenticated">
|
||||
<RouterLink class="btn primary-action" :to="memberAction?.to || '/apps'">
|
||||
{{ memberAction?.label || "Open Services" }}
|
||||
</RouterLink>
|
||||
<RouterLink class="btn secondary" to="/account">Account</RouterLink>
|
||||
<button class="btn tertiary button-like" type="button" @click="doLogout">Sign Out</button>
|
||||
<p v-if="memberState.error" class="member-error">{{ memberState.error }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="member-action-group">
|
||||
<button class="btn primary-action button-like" type="button" @click="doLogin">Sign In</button>
|
||||
<RouterLink class="btn secondary" to="/request-access">Register</RouterLink>
|
||||
</div>
|
||||
<a v-if="auth.resetUrl" class="member-reset" :href="auth.resetUrl" target="_blank" rel="noopener noreferrer">
|
||||
Trouble signing in? Reset password
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="member-service-row" aria-label="Member service access">
|
||||
<div v-for="service in content.memberServices" :key="service.name" class="member-service-item">
|
||||
<a
|
||||
v-if="isExternal(service.href)"
|
||||
class="member-service-link"
|
||||
:href="service.href"
|
||||
:aria-label="serviceTooltip(service)"
|
||||
:title="serviceTooltip(service)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="member-service-icon" aria-hidden="true">{{ service.icon }}</span>
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else
|
||||
class="member-service-link"
|
||||
:to="service.href"
|
||||
:aria-label="serviceTooltip(service)"
|
||||
:title="serviceTooltip(service)"
|
||||
>
|
||||
<span class="member-service-icon" aria-hidden="true">{{ service.icon }}</span>
|
||||
</RouterLink>
|
||||
<span class="member-service-tooltip" aria-hidden="true">
|
||||
<strong>{{ service.name }}</strong>
|
||||
<span>{{ service.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from "vue-router";
|
||||
import { auth, login, logout } from "../auth";
|
||||
|
||||
defineProps({
|
||||
content: Object,
|
||||
memberPanelCopy: String,
|
||||
memberAction: Object,
|
||||
memberState: Object,
|
||||
});
|
||||
|
||||
function isExternal(href) {
|
||||
return typeof href === "string" && /^https?:\/\//.test(href);
|
||||
}
|
||||
|
||||
function serviceTooltip(service) {
|
||||
return `${service.name}: ${service.description}`;
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
login();
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversion-section {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
scroll-margin-top: 0;
|
||||
margin-top: 0;
|
||||
padding: clamp(54px, 7vh, 82px) 0;
|
||||
}
|
||||
|
||||
.conversion-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: clamp(28px, 4vw, 42px);
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.conversion-card h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 46px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.conversion-card p {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
#member-access {
|
||||
padding-top: clamp(40px, 5vh, 64px);
|
||||
padding-bottom: clamp(34px, 5vh, 56px);
|
||||
}
|
||||
|
||||
.member-panel {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||
gap: 18px 28px;
|
||||
align-items: center;
|
||||
padding: clamp(22px, 3vw, 30px);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0.045), rgba(5, 12, 28, 0.72)),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.member-panel h2 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
}
|
||||
|
||||
.member-panel p {
|
||||
max-width: 740px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.member-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-service-row {
|
||||
position: relative;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-top: 0;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.member-service-item {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.member-service-link {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 229, 197, 0.22);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 229, 197, 0.08), rgba(120, 180, 255, 0.06)),
|
||||
rgba(255, 255, 255, 0.035);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.member-service-link:hover,
|
||||
.member-service-link:focus-visible {
|
||||
border-color: rgba(0, 229, 197, 0.58);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 229, 197, 0.16), rgba(120, 180, 255, 0.12)),
|
||||
rgba(255, 255, 255, 0.055);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.member-service-icon {
|
||||
font-size: 19px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-service-tooltip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 10px);
|
||||
z-index: 30;
|
||||
width: max-content;
|
||||
max-width: min(250px, 74vw);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 229, 197, 0.22);
|
||||
background: rgba(4, 11, 24, 0.96);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease,
|
||||
visibility 140ms ease;
|
||||
}
|
||||
|
||||
.member-service-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-right: 1px solid rgba(0, 229, 197, 0.22);
|
||||
border-bottom: 1px solid rgba(0, 229, 197, 0.22);
|
||||
background: rgba(4, 11, 24, 0.96);
|
||||
transform: translate(-50%, -5px) rotate(45deg);
|
||||
}
|
||||
|
||||
.member-service-tooltip strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.member-service-tooltip span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.member-service-item:hover .member-service-tooltip,
|
||||
.member-service-item:focus-within .member-service-tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.member-state-copy {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.member-action-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(5, 12, 28, 0.55);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.member-action-group .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: linear-gradient(135deg, rgba(0, 229, 197, 0.9), rgba(120, 180, 255, 0.82));
|
||||
color: #02141d;
|
||||
}
|
||||
|
||||
.tertiary {
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.button-like {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.member-loading {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
width: 96px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.member-reset,
|
||||
.member-error {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-reset {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.member-service-link:focus-visible {
|
||||
outline: 2px solid rgba(0, 229, 197, 0.75);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.conversion-card,
|
||||
.member-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.conversion-section {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
justify-self: stretch;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.conversion-section {
|
||||
margin-top: 0;
|
||||
padding: 50px 0;
|
||||
}
|
||||
|
||||
.member-service-row {
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.member-service-link {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.member-service-icon {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,398 +0,0 @@
|
||||
<template>
|
||||
<section id="capabilities" class="content-section" aria-labelledby="capabilities-title">
|
||||
<div class="section-heading">
|
||||
<p class="section-kicker mono">{{ content.capabilitySection.kicker }}</p>
|
||||
<h2 id="capabilities-title">{{ content.capabilitySection.heading }}</h2>
|
||||
<p>{{ content.capabilitySection.description }}</p>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<article v-for="capability in content.capabilities" :key="capability.title" class="info-card">
|
||||
<div class="icon-badge mono" aria-hidden="true">{{ capability.icon }}</div>
|
||||
<h3>{{ capability.title }}</h3>
|
||||
<p>{{ capability.description }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="services" class="content-section" aria-labelledby="services-title">
|
||||
<div class="section-heading">
|
||||
<p class="section-kicker mono">{{ content.serviceSection.kicker }}</p>
|
||||
<h2 id="services-title">{{ content.serviceSection.heading }}</h2>
|
||||
<p>{{ content.serviceSection.description }}</p>
|
||||
</div>
|
||||
<div class="service-list">
|
||||
<article v-for="service in content.services" :key="service.title" class="service-card">
|
||||
<h3>{{ service.title }}</h3>
|
||||
<p><strong>Build path:</strong> {{ service.investigation }}</p>
|
||||
<p><strong>Outcome:</strong> {{ service.outcome }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="work" class="content-section" aria-labelledby="work-title">
|
||||
<div class="section-heading">
|
||||
<p class="section-kicker mono">{{ content.workSection.kicker }}</p>
|
||||
<h2 id="work-title">{{ content.workSection.heading }}</h2>
|
||||
<p>{{ content.workSection.description }}</p>
|
||||
</div>
|
||||
<div class="work-grid">
|
||||
<article
|
||||
v-for="item in content.selectedWork"
|
||||
:key="item.title"
|
||||
class="work-card card"
|
||||
:class="{ minor: item.weight === 'minor' }"
|
||||
>
|
||||
<div class="work-card-top">
|
||||
<div class="work-icon mono" aria-hidden="true">{{ item.icon }}</div>
|
||||
<div>
|
||||
<p class="work-kind mono">{{ item.kind }}</p>
|
||||
<h3>{{ item.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<div v-if="item.points?.length || item.links?.length" class="work-proof">
|
||||
<div v-if="item.points?.length" class="work-points" aria-label="Selected proof points">
|
||||
<span v-for="point in item.points" :key="point" class="pill mono">{{ point }}</span>
|
||||
</div>
|
||||
<div v-if="item.links?.length" class="work-links">
|
||||
<a
|
||||
v-for="link in item.links"
|
||||
:key="link.href"
|
||||
class="work-link mono"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="engagement" class="content-section" aria-labelledby="engagement-title">
|
||||
<div class="section-heading centered">
|
||||
<p class="section-kicker mono">Engagement</p>
|
||||
<h2 id="engagement-title">Ways to Work Together</h2>
|
||||
<p>
|
||||
Fixed-scope and contract pricing is based on the problem, access requirements, urgency, and expected deliverables.
|
||||
</p>
|
||||
</div>
|
||||
<div class="engagement-grid">
|
||||
<article v-for="item in content.engagementTypes" :key="item.title" class="info-card">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
<div class="duration mono">{{ item.duration }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="process" class="content-section" aria-labelledby="process-title">
|
||||
<div class="section-heading">
|
||||
<p class="section-kicker mono">Process</p>
|
||||
<h2 id="process-title">A Practical Engagement Process</h2>
|
||||
</div>
|
||||
<ol class="process-list">
|
||||
<li v-for="(step, index) in content.processSteps" :key="step.title" class="process-step">
|
||||
<span class="step-number mono">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<h3>{{ step.title }}</h3>
|
||||
<p>{{ step.description }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="safety-note">
|
||||
Do not email passwords, private keys, tokens, customer data, or other secrets. Secure access can be arranged after
|
||||
scope is confirmed.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
content: Object,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-section {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
scroll-margin-top: 0;
|
||||
margin-top: 0;
|
||||
padding: clamp(58px, 7vh, 88px) 0;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
max-width: 820px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.section-heading.centered {
|
||||
width: min(100%, 920px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 46px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.card-grid,
|
||||
.work-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.engagement-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.service-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.work-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.work-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, rgba(0, 229, 197, 0.7), rgba(120, 180, 255, 0.4), transparent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.work-card.minor {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.7fr) minmax(280px, 1fr) minmax(250px, 0.8fr);
|
||||
gap: 14px 22px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.work-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.work-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 229, 197, 0.25);
|
||||
color: var(--accent-cyan);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 229, 197, 0.1), rgba(120, 180, 255, 0.08)),
|
||||
rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.work-kind {
|
||||
margin: 0 0 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.info-card h3,
|
||||
.service-card h3,
|
||||
.work-card h3,
|
||||
.process-step h3 {
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.info-card p,
|
||||
.service-card p,
|
||||
.work-card p,
|
||||
.process-step p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.work-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.work-proof {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.work-links,
|
||||
.work-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.work-points {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.work-points .pill {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.work-link {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 229, 197, 0.22);
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
background: rgba(0, 229, 197, 0.05);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.work-card.minor .work-card-top {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.work-card.minor .work-proof {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.icon-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 229, 197, 0.22);
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 229, 197, 0.06);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.service-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.service-card strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--accent-cyan);
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.process-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 229, 197, 0.1);
|
||||
border: 1px solid rgba(0, 229, 197, 0.22);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.safety-note {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid rgba(255, 220, 120, 0.45);
|
||||
background: rgba(255, 220, 120, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.card-grid,
|
||||
.work-grid,
|
||||
.service-list,
|
||||
.engagement-grid,
|
||||
.process-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.card-grid,
|
||||
.engagement-grid,
|
||||
.work-grid,
|
||||
.service-list,
|
||||
.process-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-top: 0;
|
||||
padding: 50px 0;
|
||||
}
|
||||
|
||||
.work-card.minor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -350,7 +350,7 @@ function pickIcon(name) {
|
||||
if (h.includes("translation")) return "TR";
|
||||
if (h.includes("grafana")) return "GF";
|
||||
if (h.includes("pegasus")) return "PG";
|
||||
if (h.includes("cassandra")) return "CA";
|
||||
if (h.includes("veles")) return "VL";
|
||||
if (h.includes("ai chat")) return "AI";
|
||||
if (h.includes("ai image") || h.includes("vision")) return "VI";
|
||||
if (h.includes("ai speech")) return "SP";
|
||||
@ -366,7 +366,7 @@ function serviceRank(service) {
|
||||
"Flux",
|
||||
"Grafana",
|
||||
"OpenSearch",
|
||||
"Cassandra",
|
||||
"Veles",
|
||||
"Nextcloud",
|
||||
"Outline",
|
||||
"Planka",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<RouterLink class="profile" :to="homePath" @click="closeMenu">
|
||||
<RouterLink class="profile" to="/" @click="closeMenu">
|
||||
<div class="avatar" aria-hidden="true">
|
||||
<img src="@/assets/profile-avatar.jpg" alt="" />
|
||||
</div>
|
||||
@ -36,20 +36,7 @@
|
||||
<a v-for="link in sectionLinks" :key="link.href" class="nav-link" :href="link.href" @click="closeMenu">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
<a class="nav-link professional-action" :href="`${homePath}#contact`" @click="closeMenu">Contact</a>
|
||||
|
||||
<div class="mode-switch" aria-label="Homepage mode">
|
||||
<RouterLink
|
||||
v-for="mode in homepageModes"
|
||||
:key="mode.id"
|
||||
class="nav-link mode-link"
|
||||
:class="{ active: mode.id === currentMode }"
|
||||
:to="mode.path"
|
||||
@click="closeMenu"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<a class="nav-link professional-action" href="/#contact" @click="closeMenu">Contact</a>
|
||||
|
||||
<div class="utility">
|
||||
<template v-if="!auth.ready">
|
||||
@ -75,29 +62,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
import { nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { auth, login, logout } from "@/auth";
|
||||
import {
|
||||
homepageContentForMode,
|
||||
homepageModeFromRoute,
|
||||
homepageModes,
|
||||
homepagePathForMode,
|
||||
} from "@/data/homepageContent";
|
||||
import { homepageContent } from "@/data/homepageContent";
|
||||
import { useMemberAccessState } from "@/member/useMemberAccessState";
|
||||
|
||||
const { memberAction } = useMemberAccessState();
|
||||
const route = useRoute();
|
||||
const currentMode = computed(() => homepageModeFromRoute(route));
|
||||
const content = computed(() => homepageContentForMode(currentMode.value));
|
||||
const homePath = computed(() => homepagePathForMode(currentMode.value));
|
||||
const content = homepageContent;
|
||||
|
||||
const sectionLinks = computed(() => [
|
||||
{ label: "Work", href: `${homePath.value}#work` },
|
||||
{ label: "Capabilities", href: `${homePath.value}#capabilities` },
|
||||
{ label: "Platform", href: `${homePath.value}#platform` },
|
||||
{ label: "Process", href: `${homePath.value}#process` },
|
||||
]);
|
||||
const sectionLinks = [
|
||||
{ label: "Work", href: "/#work" },
|
||||
{ label: "Capabilities", href: "/#capabilities" },
|
||||
{ label: "Platform", href: "/#platform" },
|
||||
{ label: "Process", href: "/#process" },
|
||||
];
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const navEl = ref(null);
|
||||
@ -124,10 +103,6 @@ function onWindowKeydown(event) {
|
||||
menuButton.value?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: Keep the opened mobile menu keyboard-contained and return Escape focus to the menu toggle.
|
||||
* @param {KeyboardEvent} event Navigation key event from the primary nav.
|
||||
*/
|
||||
function onNavKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
@ -260,15 +235,6 @@ onUnmounted(() => {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
@ -283,18 +249,6 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-link {
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mode-link.active {
|
||||
color: var(--accent-cyan);
|
||||
border-color: rgba(0, 229, 197, 0.28);
|
||||
background: rgba(0, 229, 197, 0.07);
|
||||
}
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@ -382,16 +336,6 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
padding-top: 10px;
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
justify-content: flex-start;
|
||||
white-space: normal;
|
||||
|
||||
@ -35,12 +35,6 @@
|
||||
<span class="k mono">GPU</span>
|
||||
<strong class="mono">{{ wolf.gpuPriority }}</strong>
|
||||
</div>
|
||||
<div class="wolf-summary-item">
|
||||
<span class="k mono">Hermes</span>
|
||||
<strong class="mono" :class="wolf.localInferenceReady ? 'ok-text' : 'warn-text'">
|
||||
{{ wolf.inferencePath }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="wolf-summary-item">
|
||||
<span class="k mono">Firewall</span>
|
||||
<strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'">
|
||||
@ -66,14 +60,6 @@
|
||||
<span class="k mono">Paired devices</span>
|
||||
<span class="v mono">{{ pairedDeviceNames }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">GPU owner</span>
|
||||
<span class="v mono">{{ wolf.gpuOwner }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">Local model</span>
|
||||
<span class="v mono">{{ wolf.localModel }} · {{ wolf.localModelLoaded ? "loaded" : "unloaded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@ -107,7 +93,7 @@
|
||||
<div v-if="wolf.canControlGpu" class="secret-box">
|
||||
<div class="secret-head">
|
||||
<div class="pill mono">Admin</div>
|
||||
<span class="hint mono">RTX 3080 checkout</span>
|
||||
<span class="hint mono">GPU priority</span>
|
||||
</div>
|
||||
<div class="wolf-controls">
|
||||
<select v-model="wolf.selectedGame" class="input mono">
|
||||
@ -115,19 +101,15 @@
|
||||
<option value="arc-raiders">Arc Raiders</option>
|
||||
<option value="satisfactory">Satisfactory</option>
|
||||
<option value="wolf">Wolf</option>
|
||||
<option value="desktop">Desktop + Wolf</option>
|
||||
</select>
|
||||
<input v-model="wolf.note" class="input mono" type="text" placeholder="note" />
|
||||
<button class="primary" type="button" :disabled="Boolean(wolf.actioning)" @click="$emit('game-mode', 'start')">
|
||||
{{ wolf.actioning === "start" ? "Reserving..." : "Reserve RTX 3080" }}
|
||||
{{ wolf.actioning === "start" ? "Starting..." : "Prioritize Wolf" }}
|
||||
</button>
|
||||
<button class="copy mono" type="button" :disabled="Boolean(wolf.actioning)" @click="$emit('game-mode', 'stop')">
|
||||
{{ wolf.actioning === "stop" ? "Releasing..." : "Release for images" }}
|
||||
{{ wolf.actioning === "stop" ? "Stopping..." : "Restore AI" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint mono">
|
||||
Desktop and Wolf may share this reservation. Local image generation waits until it is released.
|
||||
</div>
|
||||
<div class="wolf-controls manual-unlock">
|
||||
<input v-model="wolf.manualIp" class="input mono" type="text" placeholder="IP address" />
|
||||
<input v-model="wolf.manualUser" class="input mono" type="text" placeholder="user" />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const focusMode = (import.meta.env?.VITE_HOMEPAGE_FOCUS || "sdet").trim().toLowerCase();
|
||||
const focusMode = (import.meta.env?.VITE_HOMEPAGE_FOCUS || "devops").trim().toLowerCase();
|
||||
|
||||
const focusProfiles = {
|
||||
devops: {
|
||||
@ -6,27 +6,21 @@ const focusProfiles = {
|
||||
role: "DevOps Automation Engineer / Senior SDET",
|
||||
shortRole: "DevOps Automation / Platform",
|
||||
summary:
|
||||
"I build custom software, tooling, and infrastructure that make development and test workflows easier, safer, and more repeatable: platform tools, CI/CD, Docker, Kubernetes, Linux systems, Python automation, observability, and release gates.",
|
||||
"I build and repair the machinery that gets code safely into production: CI/CD, Docker, Kubernetes, Linux infrastructure, Python automation, observability, and test gates.",
|
||||
experienceLine:
|
||||
"Engineering experience across Boeing, IBM, deployment automation, infrastructure tooling, system testing, and production-minded platform operations.",
|
||||
techStack: [
|
||||
"Kubernetes",
|
||||
"Jenkins",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"Linux",
|
||||
"Terraform",
|
||||
"Ansible",
|
||||
"Flux",
|
||||
"Helm",
|
||||
"Jenkins",
|
||||
"Harbor",
|
||||
"Keycloak",
|
||||
"Vault",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
"Python",
|
||||
"Bash",
|
||||
"Pytest",
|
||||
"Selenium",
|
||||
"Playwright",
|
||||
],
|
||||
capabilityOrder: [
|
||||
@ -63,7 +57,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Inspectable repos, live services, and professional work samples that show how I approach real infrastructure.",
|
||||
},
|
||||
selectedWorkOrder: ["Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing", "Cassandra"],
|
||||
selectedWorkOrder: ["Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing", "Veles"],
|
||||
engagementOrder: [
|
||||
"Fixed-Scope Technical Rescue",
|
||||
"Short-Term Remote Contract",
|
||||
@ -75,7 +69,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Send the problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
cassandra: {
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI playtester for Magic: The Gathering deck construction, deployed for the community on Atlas with backend orchestration and disposable simulation workers.",
|
||||
points: ["community AI service", "Kubernetes deployment", "worker orchestration", "job cleanup"],
|
||||
@ -129,15 +123,14 @@ const focusProfiles = {
|
||||
"Pytest",
|
||||
"Selenium",
|
||||
"Playwright",
|
||||
"API Testing",
|
||||
"E2E Testing",
|
||||
"CI/CD",
|
||||
"API testing",
|
||||
"Jenkins",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"Linux",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
"Terraform",
|
||||
],
|
||||
capabilityOrder: [
|
||||
"Test Automation & SDET",
|
||||
@ -173,7 +166,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Engineering work across system testing, cloud validation, delivery gates, and live platform operations.",
|
||||
},
|
||||
selectedWorkOrder: ["Boeing", "IBM", "Titan Lab + titan-iac", "Atlas operator tools", "Cassandra"],
|
||||
selectedWorkOrder: ["Boeing", "IBM", "Titan Lab + titan-iac", "Atlas operator tools", "Veles"],
|
||||
engagementOrder: [
|
||||
"Short-Term Remote Contract",
|
||||
"Fixed-Scope Technical Rescue",
|
||||
@ -185,7 +178,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Send the problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
cassandra: {
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI playtester with simulation flows, backend service boundaries, and useful surfaces for API, worker, regression, and result-quality testing.",
|
||||
points: ["simulation flows", "API coverage", "regression surfaces", "result checks"],
|
||||
@ -226,7 +219,7 @@ const focusProfiles = {
|
||||
],
|
||||
},
|
||||
},
|
||||
developer: {
|
||||
dev: {
|
||||
eyebrow: "AVAILABLE FOR REMOTE PRODUCT, PLATFORM, AND AI SERVICE WORK",
|
||||
role: "Full-Stack Platform Engineer / Senior SDET",
|
||||
shortRole: "Product / Platform Engineering",
|
||||
@ -237,17 +230,15 @@ const focusProfiles = {
|
||||
techStack: [
|
||||
"Vue",
|
||||
"Python",
|
||||
"Flask",
|
||||
"API Design",
|
||||
"PostgreSQL",
|
||||
"Redis",
|
||||
"Workers",
|
||||
"AI Integration",
|
||||
"AI integration",
|
||||
"API design",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"CI/CD",
|
||||
"Jenkins",
|
||||
"Flux",
|
||||
"Grafana",
|
||||
"PostgreSQL",
|
||||
"Pytest",
|
||||
"Selenium",
|
||||
"Playwright",
|
||||
],
|
||||
capabilityOrder: [
|
||||
@ -284,7 +275,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Inspectable repos, live services, and professional work samples that show product engineering backed by real operations.",
|
||||
},
|
||||
selectedWorkOrder: ["Cassandra", "Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing"],
|
||||
selectedWorkOrder: ["Veles", "Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing"],
|
||||
engagementOrder: [
|
||||
"Short-Term Remote Contract",
|
||||
"Fixed-Scope Technical Rescue",
|
||||
@ -296,7 +287,7 @@ const focusProfiles = {
|
||||
description:
|
||||
"Send the idea or problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
cassandra: {
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI service for Magic: The Gathering deck construction, with product workflow, AI integration, backend orchestration, content seeding, and disposable simulation jobs.",
|
||||
points: ["AI integration", "product workflow", "service boundaries", "simulation workers"],
|
||||
@ -324,7 +315,7 @@ const focusProfiles = {
|
||||
},
|
||||
{
|
||||
label: "Testing",
|
||||
items: ["Pytest", "Selenium", "Playwright", "Jest", "API", "E2E", "Regression", "Contract"],
|
||||
items: ["Pytest", "Playwright", "Selenium", "Jest", "API", "E2E", "Regression", "Contract"],
|
||||
},
|
||||
{
|
||||
label: "Observability",
|
||||
@ -339,40 +330,7 @@ const focusProfiles = {
|
||||
},
|
||||
};
|
||||
|
||||
export const homepageModes = [
|
||||
{ id: "sdet", label: "SDET", path: "/" },
|
||||
{ id: "devops", label: "DevOps", path: "/devops" },
|
||||
{ id: "developer", label: "Developer", path: "/developer" },
|
||||
];
|
||||
|
||||
const focusAliases = {
|
||||
dev: "developer",
|
||||
development: "developer",
|
||||
qa: "sdet",
|
||||
test: "sdet",
|
||||
testing: "sdet",
|
||||
};
|
||||
|
||||
export function normalizeHomepageFocus(mode = "") {
|
||||
const normalized = String(mode || "").trim().toLowerCase();
|
||||
const aliased = focusAliases[normalized] || normalized;
|
||||
return focusProfiles[aliased] ? aliased : "sdet";
|
||||
}
|
||||
|
||||
export const homepageFocus = normalizeHomepageFocus(focusMode);
|
||||
|
||||
export function homepagePathForMode(mode = homepageFocus) {
|
||||
return homepageModes.find((item) => item.id === normalizeHomepageFocus(mode))?.path || "/";
|
||||
}
|
||||
|
||||
export function homepageAboutPathForMode(mode = homepageFocus) {
|
||||
const focus = normalizeHomepageFocus(mode);
|
||||
return focus === "sdet" ? "/about" : `/${focus}/about`;
|
||||
}
|
||||
|
||||
export function homepageModeFromRoute(route) {
|
||||
return normalizeHomepageFocus(route?.params?.focus || route?.meta?.homepageFocus || route?.query?.focus || homepageFocus);
|
||||
}
|
||||
export const homepageFocus = focusProfiles[focusMode] ? focusMode : "devops";
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
@ -463,6 +421,8 @@ function orderedBy(items, order) {
|
||||
return [...items].sort((a, b) => (rank.get(a.title) ?? 99) - (rank.get(b.title) ?? 99));
|
||||
}
|
||||
|
||||
const selectedFocus = focusProfiles[homepageFocus];
|
||||
|
||||
const workDetailsByFocus = {
|
||||
devops: {
|
||||
titan: {
|
||||
@ -523,7 +483,7 @@ const workDetailsByFocus = {
|
||||
points: ["Python", "Selenium", "system E2E", "microservices", "web UI", "quality gates", "test architecture"],
|
||||
},
|
||||
},
|
||||
developer: {
|
||||
dev: {
|
||||
titan: {
|
||||
kind: "Live platform",
|
||||
description:
|
||||
@ -549,16 +509,13 @@ const workDetailsByFocus = {
|
||||
},
|
||||
};
|
||||
|
||||
export function homepageContentForMode(mode = homepageFocus) {
|
||||
const focus = normalizeHomepageFocus(mode);
|
||||
const selectedFocus = focusProfiles[focus];
|
||||
const workDetails = workDetailsByFocus[focus] || workDetailsByFocus.devops;
|
||||
const workDetails = workDetailsByFocus[homepageFocus] || workDetailsByFocus.devops;
|
||||
|
||||
return {
|
||||
export const homepageContent = {
|
||||
professionalName: "Brad Stein",
|
||||
role: selectedFocus.role,
|
||||
shortRole: selectedFocus.shortRole,
|
||||
focus,
|
||||
focus: homepageFocus,
|
||||
eyebrow: selectedFocus.eyebrow,
|
||||
title: "Brad Stein | SDET, DevOps Automation & Platform Engineering",
|
||||
description:
|
||||
@ -575,7 +532,7 @@ export function homepageContentForMode(mode = homepageFocus) {
|
||||
email: "brad@bstein.dev",
|
||||
subject: "Project, contract, or engineering inquiry from bstein.dev",
|
||||
},
|
||||
resumeUrl: homepageAboutPathForMode(focus),
|
||||
resumeUrl: "/about",
|
||||
resumePdfUrl: "",
|
||||
linkedInUrl: "https://www.linkedin.com/in/steinbradley/",
|
||||
sourceUrl: "https://scm.bstein.dev/bstein",
|
||||
@ -640,18 +597,18 @@ export function homepageContentForMode(mode = homepageFocus) {
|
||||
{
|
||||
icon: "AI",
|
||||
kind: "Community AI service",
|
||||
title: "Cassandra",
|
||||
description: selectedFocus.cassandra.description,
|
||||
points: selectedFocus.cassandra.points,
|
||||
title: "Veles",
|
||||
description: selectedFocus.veles.description,
|
||||
points: selectedFocus.veles.points,
|
||||
weight: "minor",
|
||||
links: [
|
||||
{
|
||||
label: "Live app",
|
||||
href: "https://cassandra.bstein.dev/",
|
||||
href: "https://veles.bstein.dev/",
|
||||
},
|
||||
{
|
||||
label: "Content seed",
|
||||
href: "https://scm.bstein.dev/bstein/cassandra-content",
|
||||
href: "https://scm.bstein.dev/bstein/veles-content",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -682,10 +639,8 @@ export function homepageContentForMode(mode = homepageFocus) {
|
||||
title: "Senior Remote Engineering Role",
|
||||
duration: "Full-time remote roles where the fit is strong.",
|
||||
description:
|
||||
focus === "devops"
|
||||
homepageFocus === "devops"
|
||||
? "I am open to well-aligned remote DevOps Automation, platform engineering, CI/CD, infrastructure automation, Kubernetes, Linux, or systems reliability roles. SDET and test automation depth comes with it."
|
||||
: focus === "developer"
|
||||
? "I am open to well-aligned remote developer, full-stack, platform product, AI service, Python, Vue, automation, or internal tooling roles. SDET and DevOps depth comes with it."
|
||||
: "I am open to well-aligned remote SDET, QA Automation, systems testing, release quality, CI/CD, or DevOps Automation roles. Platform engineering depth comes with it.",
|
||||
},
|
||||
].sort((a, b) => {
|
||||
@ -827,9 +782,9 @@ export function homepageContentForMode(mode = homepageFocus) {
|
||||
description: "Ask the Titan Lab AI assistant for help.",
|
||||
},
|
||||
{
|
||||
name: "Cassandra",
|
||||
name: "Veles",
|
||||
icon: "🃏",
|
||||
href: "https://cassandra.bstein.dev/",
|
||||
href: "https://veles.bstein.dev/",
|
||||
description: "Test Magic: The Gathering deck ideas with AI playtesting.",
|
||||
},
|
||||
{
|
||||
@ -839,13 +794,10 @@ export function homepageContentForMode(mode = homepageFocus) {
|
||||
description: "Use the private RPC endpoint through the Monero guide.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const homepageContent = homepageContentForMode(homepageFocus);
|
||||
|
||||
export function contactMailto(content = homepageContent) {
|
||||
const email = content.primaryContact.email;
|
||||
const subject = encodeURIComponent(content.primaryContact.subject);
|
||||
export function contactMailto() {
|
||||
const email = homepageContent.primaryContact.email;
|
||||
const subject = encodeURIComponent(homepageContent.primaryContact.subject);
|
||||
return `mailto:${email}?subject=${subject}`;
|
||||
}
|
||||
|
||||
@ -234,12 +234,12 @@ export function fallbackServices() {
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
name: "Cassandra",
|
||||
name: "Veles",
|
||||
icon: "🃏",
|
||||
category: "ai",
|
||||
summary: "Magic: The Gathering AI playtester for deck construction.",
|
||||
link: "https://cassandra.bstein.dev/",
|
||||
host: "cassandra.bstein.dev",
|
||||
link: "https://veles.bstein.dev/",
|
||||
host: "veles.bstein.dev",
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
|
||||
@ -12,11 +12,8 @@ import OnboardingView from "./views/OnboardingView.vue";
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", name: "home", component: HomeView, meta: { homepageFocus: "sdet" } },
|
||||
{ path: "/about", name: "about", component: AboutView, meta: { homepageFocus: "sdet" } },
|
||||
{ path: "/dev", redirect: "/developer" },
|
||||
{ path: "/:focus(devops|developer|sdet)", name: "home-focus", component: HomeView },
|
||||
{ path: "/:focus(devops|developer|sdet)/about", name: "about-focus", component: AboutView },
|
||||
{ path: "/", name: "home", component: HomeView },
|
||||
{ path: "/about", name: "about", component: AboutView },
|
||||
{ path: "/ai", redirect: "/ai/chat" },
|
||||
{ path: "/ai/chat", name: "ai-chat", component: AiView },
|
||||
{ path: "/ai/roadmap", name: "ai-roadmap", component: AiPlanView },
|
||||
|
||||
@ -63,6 +63,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<p class="note">
|
||||
Web version aligned to <span class="mono">Stein.Brad.Resume.2026.06.pdf</span>. Phone and street address are
|
||||
intentionally omitted from the public site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@ -83,11 +88,10 @@
|
||||
and operator workflows instead of leaving them as manual notes.
|
||||
</p>
|
||||
<p>
|
||||
Local hosting gives me room to build and operate in-development AI-powered systems on the same platform.
|
||||
<span class="mono">Cassandra</span>, an AI-powered Magic: The Gathering playtester that uses AI as a decision
|
||||
engine, runs at
|
||||
<a href="https://cassandra.bstein.dev/" target="_blank" rel="noopener noreferrer">cassandra.bstein.dev</a>
|
||||
and takes advantage of the multi-architecture Atlas cluster and its supporting systems.
|
||||
<span class="mono">Oceanus</span> and <span class="mono">Tethys</span> are now part of Atlas capacity instead of separate public status surfaces.
|
||||
The dedicated non-cluster hosts are the control-plane database node and the jumphost. <span class="mono">Veles</span>,
|
||||
the MTG AI playtester, is deployed at
|
||||
<a href="https://veles.bstein.dev/" target="_blank" rel="noopener noreferrer">veles.bstein.dev</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -99,9 +103,11 @@
|
||||
</div>
|
||||
<div class="copy">
|
||||
<p>
|
||||
Mechanical Engineering and Applied Math at the University of Texas at Austin, including Java, C++, probability,
|
||||
statistics, complex analysis, numerical methods, number theory, and differential equations. Emerging Scholars
|
||||
honors calculus student and later served as a teaching assistant for it.
|
||||
Studied Mechanical Engineering and Applied Math at the University of Texas at Austin, including Java, C++,
|
||||
probability, statistics, complex analysis, numerical methods, number theory, and differential equations.
|
||||
</p>
|
||||
<p>
|
||||
Participated in the Emerging Scholars honors calculus program and later served as an assistant for it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -109,13 +115,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { homepageContentForMode, homepageModeFromRoute } from "../data/homepageContent";
|
||||
import { homepageContent } from "../data/homepageContent";
|
||||
|
||||
const route = useRoute();
|
||||
const content = computed(() => homepageContentForMode(homepageModeFromRoute(route)));
|
||||
const about = computed(() => content.value.about);
|
||||
const about = homepageContent.about;
|
||||
|
||||
const timeline = [
|
||||
{
|
||||
|
||||
@ -85,10 +85,11 @@ const apiDisplay = apiUrl.host + apiUrl.pathname;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const profiles = [
|
||||
{ id: "atlas-quick", label: "Atlas Quick" },
|
||||
{ id: "atlas-smart", label: "Atlas Smart" },
|
||||
{ id: "atlas-genius", label: "Atlas Genius" },
|
||||
];
|
||||
const activeProfile = ref("atlas-smart");
|
||||
const activeProfile = ref("atlas-quick");
|
||||
const profileState = reactive(
|
||||
Object.fromEntries(
|
||||
profiles.map((profile) => [
|
||||
@ -243,8 +244,7 @@ function handleKeydown(e) {
|
||||
|
||||
async function copyCurl() {
|
||||
const target = current.value.meta.endpoint || apiUrl.toString();
|
||||
const body = JSON.stringify({ message: "hi", profile: activeProfile.value });
|
||||
const curl = `curl -X POST ${target} -H 'content-type: application/json' -d '${body}'`;
|
||||
const curl = `curl -X POST ${target} -H 'content-type: application/json' -d '{\"message\":\"hi\"}'`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(curl);
|
||||
copied.value = true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -49,7 +49,7 @@ test.beforeEach(async ({ page }) => {
|
||||
body: JSON.stringify({ enabled: true, reset_url: "https://sso.example.dev/reset" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/lab/status*", async (route) => {
|
||||
await page.route("**/api/lab/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -77,18 +77,15 @@ test("shows Brad's professional homepage and opens the platform diagram overlay"
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Brad Stein" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Senior SDET / DevOps Automation Engineer" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "DevOps Automation Engineer / Senior SDET" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Discuss a Project" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "View Résumé" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Explore the Live Platform" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Login" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Primary navigation").getByRole("link", { name: "Register" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Quality Systems I Can Take From Zero to Reliable" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Register" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "From 0 to 100" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Nextcloud", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("2 of 2 online")).toBeVisible();
|
||||
await expect(page.getByText("Database machine")).toBeVisible();
|
||||
await expect(page.getByText("2 of 2 responding")).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Explore the Live Platform" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Titan Lab: Live Platform" })).toBeVisible();
|
||||
@ -109,7 +106,7 @@ test("keeps the homepage usable when public status fails", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.__LAB_STATUS_FAIL__ = true;
|
||||
});
|
||||
await page.route("**/api/lab/status*", async (route) => {
|
||||
await page.route("**/api/lab/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
@ -123,21 +120,3 @@ test("keeps the homepage usable when public status fails", async ({ page }) => {
|
||||
await expect(page.getByText("Live status is temporarily unavailable").first()).toBeVisible();
|
||||
await expect(page.getByText("status 503")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("serves developer and SDET homepage modes", async ({ page }) => {
|
||||
await page.goto("/developer", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Full-Stack Platform Engineer / Senior SDET" })).toBeVisible();
|
||||
await expect(page.getByText("Product and Platform Showcase")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Developer", exact: true })).toHaveClass(/active/);
|
||||
await page.getByRole("link", { name: "View Résumé" }).click();
|
||||
await expect(page).toHaveURL(/\/developer\/about$/);
|
||||
await expect(page.getByText("AI Services · Vue · Python · Kubernetes")).toBeVisible();
|
||||
|
||||
await page.goto("/sdet", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Senior SDET / DevOps Automation Engineer" })).toBeVisible();
|
||||
await expect(page.getByText("Testing, Automation, and Platform Work")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "SDET", exact: true })).toHaveClass(/active/);
|
||||
await page.getByRole("link", { name: "View Résumé" }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByText("Test Automation · Release Quality · CI/CD")).toBeVisible();
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ test.beforeEach(async ({ page }) => {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/lab/status*", async (route) => {
|
||||
await page.route("**/api/lab/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
|
||||
@ -6,7 +6,6 @@ const testingDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.resolve(testingDir, "component"),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
ctPort: 3100,
|
||||
ctTemplateDir: "../../frontend/playwright",
|
||||
|
||||
@ -9,7 +9,6 @@ const webServerCommand = process.env.PLAYWRIGHT_REUSE_DIST === "1" ? previewComm
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.resolve(testingDir, "e2e"),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
|
||||
@ -215,18 +215,7 @@ describe("account dashboard", () => {
|
||||
return jsonResponse({
|
||||
can_control_gpu: true,
|
||||
moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 },
|
||||
gpu: {
|
||||
priority: "ai",
|
||||
game_mode: {
|
||||
status: "idle",
|
||||
active: false,
|
||||
gpu_owner: "hermes",
|
||||
model: "gpt-oss:20b",
|
||||
model_loaded: true,
|
||||
local_inference_ready: true,
|
||||
inference_path: "local",
|
||||
},
|
||||
},
|
||||
gpu: { priority: "ai", game_mode: { status: "idle", active: false } },
|
||||
wolf: {
|
||||
api_enabled: true,
|
||||
clients: [{ name: "Desktop" }],
|
||||
@ -247,18 +236,13 @@ describe("account dashboard", () => {
|
||||
|
||||
expect(dashboard.wolf.status).toBe("ready");
|
||||
expect(dashboard.wolf.canControlGpu).toBe(true);
|
||||
expect(dashboard.wolf.gpuOwner).toBe("hermes");
|
||||
expect(dashboard.wolf.localModel).toBe("gpt-oss:20b");
|
||||
expect(dashboard.wolf.localModelLoaded).toBe(true);
|
||||
expect(dashboard.wolf.localInferenceReady).toBe(true);
|
||||
expect(dashboard.wolf.inferencePath).toBe("local");
|
||||
expect(dashboard.wolf.clients[0].name).toBe("Desktop");
|
||||
expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");
|
||||
|
||||
await dashboard.unlockWolf();
|
||||
dashboard.wolf.pinInputs["secret-1"] = "1234";
|
||||
await dashboard.pairWolf("secret-1");
|
||||
dashboard.wolf.selectedGame = "desktop";
|
||||
dashboard.wolf.selectedGame = "arc-raiders";
|
||||
dashboard.wolf.note = "now";
|
||||
await dashboard.setWolfGameMode("start");
|
||||
await dashboard.setWolfGameMode("stop");
|
||||
@ -276,7 +260,7 @@ describe("account dashboard", () => {
|
||||
source_ip: "181.1.87.186",
|
||||
});
|
||||
expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({
|
||||
game: "desktop",
|
||||
game: "arc-raiders",
|
||||
note: "now",
|
||||
});
|
||||
expect(JSON.parse(seen.find((item) => item.url.includes("/admin/firewall/unlock")).body)).toEqual({
|
||||
|
||||
@ -55,7 +55,7 @@ describe("AI chat view", () => {
|
||||
installFetch((url) => {
|
||||
if (url.includes("/api/ai/info")) {
|
||||
return jsonResponse({
|
||||
model: url.includes("atlas-genius") ? "genius-model" : "smart-model",
|
||||
model: url.includes("atlas-smart") ? "smart-model" : "quick-model",
|
||||
gpu: "titan-24",
|
||||
node: "titan-24",
|
||||
endpoint: "https://ai.example.dev/chat",
|
||||
@ -76,18 +76,16 @@ describe("AI chat view", () => {
|
||||
const wrapper = mount(AiView);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).not.toContain("Atlas Quick");
|
||||
expect(wrapper.text()).toContain("smart-model");
|
||||
expect(wrapper.text()).toContain("quick-model");
|
||||
expect(wrapper.text()).toContain("titan-24");
|
||||
|
||||
await wrapper.find(".endpoint-copy").trigger("click");
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST https://ai.example.dev/chat"));
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('"profile":"atlas-smart"'));
|
||||
expect(wrapper.text()).toContain("copied");
|
||||
|
||||
await wrapper.findAll(".profile-tab").find((button) => button.text() === "Atlas Genius").trigger("click");
|
||||
await wrapper.findAll(".profile-tab").find((button) => button.text() === "Atlas Smart").trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("genius-model");
|
||||
expect(wrapper.text()).toContain("smart-model");
|
||||
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
@ -102,7 +100,7 @@ describe("AI chat view", () => {
|
||||
it("sends JSON chat requests and reveals typed responses", async () => {
|
||||
const bodies = [];
|
||||
installFetch((url, options) => {
|
||||
if (url.includes("/api/ai/info")) return jsonResponse({ model: "smart-model" });
|
||||
if (url.includes("/api/ai/info")) return jsonResponse({ model: "quick-model" });
|
||||
bodies.push(JSON.parse(options.body));
|
||||
return jsonResponse({ reply: "typed assistant response", latency_ms: 42 });
|
||||
});
|
||||
@ -119,10 +117,10 @@ describe("AI chat view", () => {
|
||||
expect(wrapper.text()).toContain("42 ms");
|
||||
expect(bodies[0]).toMatchObject({
|
||||
message: "hello atlas",
|
||||
profile: "atlas-smart",
|
||||
conversation_id: expect.stringContaining("atlas-smart"),
|
||||
profile: "atlas-quick",
|
||||
conversation_id: expect.stringContaining("atlas-quick"),
|
||||
});
|
||||
expect(localStorage.getItem("atlas-ai-conversation:atlas-smart")).toContain("uuid-1");
|
||||
expect(localStorage.getItem("atlas-ai-conversation:atlas-quick")).toContain("uuid-1");
|
||||
|
||||
await wrapper.find("textarea").setValue("second");
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
|
||||
@ -53,9 +53,6 @@ describe("frontend entrypoint and router", () => {
|
||||
expect(router.routes.map((route) => route.path)).toEqual([
|
||||
"/",
|
||||
"/about",
|
||||
"/dev",
|
||||
"/:focus(devops|developer|sdet)",
|
||||
"/:focus(devops|developer|sdet)/about",
|
||||
"/ai",
|
||||
"/ai/chat",
|
||||
"/ai/roadmap",
|
||||
@ -66,9 +63,6 @@ describe("frontend entrypoint and router", () => {
|
||||
"/onboarding",
|
||||
]);
|
||||
expect(router.routes.find((route) => route.path === "/ai")).toMatchObject({ redirect: "/ai/chat" });
|
||||
expect(router.routes.find((route) => route.path === "/dev")).toMatchObject({ redirect: "/developer" });
|
||||
expect(router.routes.find((route) => route.name === "home-focus").component).toBeDefined();
|
||||
expect(router.routes.find((route) => route.name === "about-focus").component).toBeDefined();
|
||||
expect(router.routes.find((route) => route.name === "account").component).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,22 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { RouterLinkStub, flushPromises, mount, shallowMount } from "@vue/test-utils";
|
||||
import { RouterLinkStub, flushPromises, shallowMount } from "@vue/test-utils";
|
||||
|
||||
import PlatformSection from "../../../frontend/src/components/PlatformSection.vue";
|
||||
import { auth } from "../../../frontend/src/auth.js";
|
||||
import { homepageContentForMode, normalizeHomepageFocus } from "../../../frontend/src/data/homepageContent.js";
|
||||
import HomeView from "../../../frontend/src/views/HomeView.vue";
|
||||
|
||||
let mockRoute = { meta: { homepageFocus: "sdet" }, params: {}, query: {} };
|
||||
|
||||
jest.mock("vue-router", () => ({
|
||||
RouterLink: {
|
||||
name: "RouterLink",
|
||||
props: ["to"],
|
||||
template: "<a :href=\"typeof to === 'string' ? to : '#'\" @click=\"$emit('click', $event)\"><slot /></a>",
|
||||
},
|
||||
useRoute: () => mockRoute,
|
||||
}), { virtual: true });
|
||||
|
||||
function resetAuth() {
|
||||
auth.ready = true;
|
||||
auth.enabled = true;
|
||||
@ -30,7 +18,6 @@ function resetAuth() {
|
||||
|
||||
describe("HomeView", () => {
|
||||
beforeEach(() => {
|
||||
mockRoute = { meta: { homepageFocus: "sdet" }, params: {}, query: {} };
|
||||
global.fetch = jest.fn(async (resource) => {
|
||||
const url = typeof resource === "string" ? resource : resource?.url || "";
|
||||
if (url.includes("/api/account/member-state")) {
|
||||
@ -55,7 +42,7 @@ describe("HomeView", () => {
|
||||
|
||||
it("renders the professional homepage while logged out", () => {
|
||||
resetAuth();
|
||||
const wrapper = mount(HomeView, {
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
@ -85,14 +72,14 @@ describe("HomeView", () => {
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Brad Stein");
|
||||
expect(wrapper.text()).toContain("Senior SDET / DevOps Automation Engineer");
|
||||
expect(wrapper.text()).toContain("DevOps Automation Engineer / Senior SDET");
|
||||
expect(wrapper.text()).toContain("Discuss a Project");
|
||||
expect(wrapper.text()).toContain("View Résumé");
|
||||
expect(wrapper.text()).toContain("Explore the Live Platform");
|
||||
expect(wrapper.text()).toContain("Quality Systems I Can Take From Zero to Reliable");
|
||||
expect(wrapper.text()).toContain("Testing, Automation, and Platform Work");
|
||||
expect(wrapper.text()).toContain("From 0 to 100");
|
||||
expect(wrapper.text()).toContain("Platform Showcase");
|
||||
expect(wrapper.text()).toContain("Atlas operator tools");
|
||||
expect(wrapper.text()).toContain("Cassandra");
|
||||
expect(wrapper.text()).toContain("Veles");
|
||||
expect(wrapper.text()).not.toContain("TaskWatcher");
|
||||
expect(wrapper.text()).toContain("family, friends, and the lucky few");
|
||||
expect(wrapper.text()).toContain("Register");
|
||||
@ -109,19 +96,17 @@ describe("HomeView", () => {
|
||||
expect(wrapper.text()).not.toContain("Looking for hosted services or an existing Titan Lab account");
|
||||
expect(wrapper.find("a[href^='mailto:brad@bstein.dev']").exists()).toBe(true);
|
||||
expect(wrapper.findComponent(PlatformSection).exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Atlas cluster");
|
||||
expect(wrapper.text()).toContain("Most requested service");
|
||||
expect(wrapper.text()).toContain("Atlas platform");
|
||||
expect(wrapper.text()).toContain("Most active service");
|
||||
expect(wrapper.text()).toContain("Nextcloud");
|
||||
expect(wrapper.text()).toContain("Outpost hosts");
|
||||
expect(wrapper.text()).toContain("2 of 2 online");
|
||||
expect(wrapper.text()).toContain("Database machine");
|
||||
expect(wrapper.text()).toContain("Jumphost");
|
||||
expect(wrapper.text()).toContain("Dedicated hosts");
|
||||
expect(wrapper.text()).toContain("2 of 2 responding");
|
||||
expect(wrapper.text()).toContain("Responding");
|
||||
});
|
||||
|
||||
it("shows a calm fallback when public status is unavailable", () => {
|
||||
resetAuth();
|
||||
const wrapper = mount(HomeView, {
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
@ -145,7 +130,7 @@ describe("HomeView", () => {
|
||||
auth.username = "ada";
|
||||
auth.email = "ada@example.dev";
|
||||
|
||||
const wrapper = mount(HomeView, {
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
@ -165,50 +150,6 @@ describe("HomeView", () => {
|
||||
expect(wrapper.text()).not.toContain("app_password");
|
||||
expect(wrapper.text()).not.toContain("mailu_app_password");
|
||||
});
|
||||
|
||||
it("builds developer and SDET homepage modes", () => {
|
||||
const devops = homepageContentForMode("devops");
|
||||
const developer = homepageContentForMode("developer");
|
||||
const sdet = homepageContentForMode("sdet");
|
||||
|
||||
expect(normalizeHomepageFocus("dev")).toBe("developer");
|
||||
expect(devops.summary).toContain("custom software, tooling, and infrastructure");
|
||||
expect(devops.techStack).toEqual([
|
||||
"Kubernetes",
|
||||
"Docker",
|
||||
"Linux",
|
||||
"Terraform",
|
||||
"Ansible",
|
||||
"Flux",
|
||||
"Helm",
|
||||
"Jenkins",
|
||||
"Harbor",
|
||||
"Keycloak",
|
||||
"Vault",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
"Python",
|
||||
"Bash",
|
||||
"Pytest",
|
||||
"Selenium",
|
||||
"Playwright",
|
||||
]);
|
||||
expect(developer.focus).toBe("developer");
|
||||
expect(developer.role).toBe("Full-Stack Platform Engineer / Senior SDET");
|
||||
expect(developer.resumeUrl).toBe("/developer/about");
|
||||
expect(developer.techStack).toContain("Vue");
|
||||
expect(developer.techStack.indexOf("Selenium")).toBeLessThan(developer.techStack.indexOf("Playwright"));
|
||||
expect(developer.workSection.heading).toBe("Product and Platform Showcase");
|
||||
expect(developer.selectedWork[0].title).toBe("Cassandra");
|
||||
|
||||
expect(sdet.focus).toBe("sdet");
|
||||
expect(sdet.role).toBe("Senior SDET / DevOps Automation Engineer");
|
||||
expect(sdet.resumeUrl).toBe("/about");
|
||||
expect(sdet.techStack).toContain("Selenium");
|
||||
expect(sdet.techStack.indexOf("Selenium")).toBeLessThan(sdet.techStack.indexOf("Playwright"));
|
||||
expect(sdet.capabilities[0].title).toBe("Test Automation & SDET");
|
||||
expect(sdet.selectedWork[0].title).toBe("Boeing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformSection", () => {
|
||||
|
||||
@ -20,7 +20,7 @@ describe("sample data builders", () => {
|
||||
expect(hardware.specialty.map((node) => node.alias)).toEqual(["atlas-db", "theia"]);
|
||||
expect(services.services.some((service) => service.name === "Keycloak")).toBe(true);
|
||||
expect(services.services.some((service) => service.name === "Oceanus")).toBe(false);
|
||||
expect(services.services.some((service) => service.name === "Cassandra")).toBe(true);
|
||||
expect(services.services.some((service) => service.name === "Veles")).toBe(true);
|
||||
expect(services.services.some((service) => service.name === "AI Chat")).toBe(true);
|
||||
expect(services.services.find((service) => service.name === "AI Chat").link).toBe("https://bstein.dev/ai/chat");
|
||||
expect(services.services.find((service) => service.name === "Monero").link).toBe("https://bstein.dev/monero");
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { flushPromises, mount, shallowMount } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
@ -27,7 +26,6 @@ jest.mock("vue-router", () => ({
|
||||
props: ["to"],
|
||||
template: "<a :href=\"typeof to === 'string' ? to : '#'\" @click=\"$emit('click', $event)\"><slot /></a>",
|
||||
},
|
||||
useRoute: () => ({ meta: { homepageFocus: "sdet" }, params: {}, query: {} }),
|
||||
}), { virtual: true });
|
||||
|
||||
describe("static shell views and components", () => {
|
||||
@ -45,7 +43,7 @@ describe("static shell views and components", () => {
|
||||
const about = shallowMount(AboutView);
|
||||
expect(about.text()).toContain("About Me");
|
||||
expect(about.text()).toContain("Titan Lab");
|
||||
expect(about.text()).toContain("Senior SDET / DevOps Automation Engineer");
|
||||
expect(about.text()).toContain("DevOps Automation Engineer / Senior SDET");
|
||||
expect(about.text()).toContain("Kubernetes");
|
||||
expect(about.text()).toContain("IBM Cloud");
|
||||
expect(about.text()).toContain("TradeHat");
|
||||
@ -125,51 +123,17 @@ describe("static shell views and components", () => {
|
||||
const logout = jest.spyOn(authModule, "logout").mockImplementation(() => {});
|
||||
jest.spyOn(authModule, "login").mockImplementation(() => {});
|
||||
|
||||
const target = document.createElement("div");
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mount(TopBar, { attachTo: target });
|
||||
|
||||
try {
|
||||
const wrapper = mount(TopBar);
|
||||
expect(wrapper.text()).toContain("Contact");
|
||||
expect(wrapper.text()).toContain("DevOps");
|
||||
expect(wrapper.text()).toContain("Developer");
|
||||
expect(wrapper.text()).toContain("SDET");
|
||||
expect(wrapper.text()).toContain("Login");
|
||||
expect(wrapper.text()).toContain("Register");
|
||||
expect(wrapper.text()).not.toContain("Reset Password");
|
||||
expect(wrapper.find("a[href='/#contact']").exists()).toBe(true);
|
||||
expect(wrapper.find("a[href='/devops']").exists()).toBe(true);
|
||||
expect(wrapper.find("a[href='/developer']").exists()).toBe(true);
|
||||
expect(wrapper.find(".mode-link.active").text()).toBe("SDET");
|
||||
|
||||
await wrapper.find("button.menu-toggle").trigger("click");
|
||||
const nav = wrapper.find("nav");
|
||||
expect(nav.classes()).toContain("open");
|
||||
const navItems = () => Array.from(nav.element.querySelectorAll("a[href], button:not([disabled])"));
|
||||
navItems()[0].focus();
|
||||
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(navItems().at(-1));
|
||||
|
||||
navItems().at(-1).focus();
|
||||
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(navItems()[0]);
|
||||
|
||||
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await nextTick();
|
||||
expect(nav.classes()).not.toContain("open");
|
||||
expect(document.activeElement).toBe(wrapper.find("button.menu-toggle").element);
|
||||
|
||||
await wrapper.find("button.menu-toggle").trigger("click");
|
||||
expect(nav.classes()).toContain("open");
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await nextTick();
|
||||
expect(nav.classes()).not.toContain("open");
|
||||
|
||||
await wrapper.find("button.menu-toggle").trigger("click");
|
||||
expect(wrapper.find("nav").classes()).toContain("open");
|
||||
await wrapper.find("a[href='/#contact']").trigger("click");
|
||||
expect(nav.classes()).not.toContain("open");
|
||||
expect(wrapper.find("nav").classes()).not.toContain("open");
|
||||
|
||||
auth.authenticated = true;
|
||||
await flushPromises();
|
||||
@ -177,10 +141,6 @@ describe("static shell views and components", () => {
|
||||
expect(wrapper.text()).toContain("Open Services");
|
||||
await wrapper.findAll("button.button").at(-1).trigger("click");
|
||||
expect(logout).toHaveBeenCalled();
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads Monero status and handles API failures", async () => {
|
||||
@ -222,13 +182,6 @@ describe("static shell views and components", () => {
|
||||
});
|
||||
await flushPromises();
|
||||
expect(app.text()).toContain("true");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/api\/lab\/status\?ts=\d+$/),
|
||||
expect.objectContaining({
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await app.unmount();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user