Fix homepage quality gate
This commit is contained in:
parent
0437bfe9b3
commit
381ecb4ea0
223
frontend/src/components/HomeContactFooter.vue
Normal file
223
frontend/src/components/HomeContactFooter.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<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>
|
||||
297
frontend/src/components/HomeHeroStatus.vue
Normal file
297
frontend/src/components/HomeHeroStatus.vue
Normal file
@ -0,0 +1,297 @@
|
||||
<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>
|
||||
<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>
|
||||
</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.bad {
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.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-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>
|
||||
391
frontend/src/components/HomeMemberAccess.vue
Normal file
391
frontend/src/components/HomeMemberAccess.vue
Normal file
@ -0,0 +1,391 @@
|
||||
<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>
|
||||
398
frontend/src/components/HomeProfessionalSections.vue
Normal file
398
frontend/src/components/HomeProfessionalSections.vue
Normal file
@ -0,0 +1,398 @@
|
||||
<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>
|
||||
@ -103,6 +103,10 @@ 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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { RouterLinkStub, flushPromises, shallowMount } from "@vue/test-utils";
|
||||
import { RouterLinkStub, flushPromises, mount, shallowMount } from "@vue/test-utils";
|
||||
|
||||
import PlatformSection from "../../../frontend/src/components/PlatformSection.vue";
|
||||
import { auth } from "../../../frontend/src/auth.js";
|
||||
@ -42,7 +42,7 @@ describe("HomeView", () => {
|
||||
|
||||
it("renders the professional homepage while logged out", () => {
|
||||
resetAuth();
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
const wrapper = mount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
@ -106,7 +106,7 @@ describe("HomeView", () => {
|
||||
|
||||
it("shows a calm fallback when public status is unavailable", () => {
|
||||
resetAuth();
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
const wrapper = mount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
@ -130,7 +130,7 @@ describe("HomeView", () => {
|
||||
auth.username = "ada";
|
||||
auth.email = "ada@example.dev";
|
||||
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
const wrapper = mount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@ -123,24 +124,56 @@ describe("static shell views and components", () => {
|
||||
const logout = jest.spyOn(authModule, "logout").mockImplementation(() => {});
|
||||
jest.spyOn(authModule, "login").mockImplementation(() => {});
|
||||
|
||||
const wrapper = mount(TopBar);
|
||||
expect(wrapper.text()).toContain("Contact");
|
||||
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);
|
||||
const target = document.createElement("div");
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mount(TopBar, { attachTo: target });
|
||||
|
||||
await wrapper.find("button.menu-toggle").trigger("click");
|
||||
expect(wrapper.find("nav").classes()).toContain("open");
|
||||
await wrapper.find("a[href='/#contact']").trigger("click");
|
||||
expect(wrapper.find("nav").classes()).not.toContain("open");
|
||||
try {
|
||||
expect(wrapper.text()).toContain("Contact");
|
||||
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);
|
||||
|
||||
auth.authenticated = true;
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("Account");
|
||||
expect(wrapper.text()).toContain("Open Services");
|
||||
await wrapper.findAll("button.button").at(-1).trigger("click");
|
||||
expect(logout).toHaveBeenCalled();
|
||||
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");
|
||||
await wrapper.find("a[href='/#contact']").trigger("click");
|
||||
expect(nav.classes()).not.toContain("open");
|
||||
|
||||
auth.authenticated = true;
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("Account");
|
||||
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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user