Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Certifications.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { certifications, person } from '../data/profile';
<h2 id="certifications-title">Proven on two clouds.</h2>
<p class="lede">Certifications on AWS, Azure and Scrum, up to the professional level.</p>
</div>
<a class="verify" href={person.credly} rel="noopener">
Verify on Credly →
<a class="verify" href={person.credly} rel="noopener" target="_blank">
Verify on Credly →<span class="sr-only"> (opens in a new tab)</span>
</a>
</div>
{certifications.map(({ tier, items }, index) => (
Expand Down
111 changes: 109 additions & 2 deletions src/components/Contact.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,40 @@ const links = [
<Brand name="email" />
Send me an email
</a>
<p>{person.email}</p>
{/* The address, copied in one tap; without JavaScript, plain text to select. */}
<p class="address">
<span class="plain">{person.email}</span>
<button
class="copy"
type="button"
data-email={person.email}
aria-label={`Copy the email address, ${person.email}`}
>
<span class="value">{person.email}</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2"></rect>
<path d="M5 15V6a2 2 0 0 1 2-2h8"></path>
</svg>
<span class="done" aria-hidden="true">
Copied
</span>
</button>
<span class="status sr-only" aria-live="polite"></span>
</p>
</div>
<ul class="links reveal">
{links.map(({ name, label, href }) => (
<li>
{/* Profiles open beside the site, so a visitor can come back to it. */}
<a
href={href}
rel="noopener"
target={name === 'cv' ? undefined : '_blank'}
download={name === 'cv' ? 'Terencio-Agozzino-CV.pdf' : undefined}
>
<Brand name={name} />
{label}
{name !== 'cv' && <span class="sr-only"> (opens in a new tab)</span>}
</a>
</li>
))}
Expand All @@ -54,6 +76,27 @@ const links = [
</div>
</footer>

<script>
const copy = document.querySelector<HTMLButtonElement>('.copy');
const status = document.querySelector('.address .status');
copy?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(copy.dataset.email!);
} catch {
// No clipboard access: select it instead, ready for the visitor to copy.
getSelection()?.selectAllChildren(copy.querySelector('.value')!);
return;
}
copy.classList.add('copied');
if (status) status.textContent = 'Email address copied';
window.goatcounter?.count({ path: 'copy email', title: 'Copy email', event: true });
setTimeout(() => {
copy.classList.remove('copied');
if (status) status.textContent = '';
}, 2000);
});
</script>

<style>
.contact {
background: var(--closing);
Expand Down Expand Up @@ -103,13 +146,77 @@ const links = [
}

/* The address itself, for whoever would rather copy it. */
.email p {
.address {
margin-top: 12px;
font-size: 14px;
color: var(--closing-soft);
}

.plain {
user-select: all;
}

.copy {
display: none;
position: relative;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
transition:
color 0.2s,
background 0.2s;
}

:global(.js) .copy {
display: inline-flex;
}

:global(.js) .plain {
display: none;
}

/* Only where there is a pointer to hover with: on a phone, a tap would leave
the highlight behind. */
@media (hover: hover) {
.copy:hover:not(.copied) {
color: #ece8da;
background: rgb(236 232 218 / 0.08);
}
}

.copy svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}

/* "Copied" stands where the icon was for two seconds; the icon keeps its
room, so the address does not move. */
.done {
display: none;
position: absolute;
left: calc(100% - 23px);
font-weight: 600;
color: var(--gold);
white-space: nowrap;
}

.copy.copied svg {
visibility: hidden;
}

.copy.copied .done {
display: inline;
}

.links {
display: flex;
justify-content: center;
Expand Down
10 changes: 8 additions & 2 deletions src/components/Research.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const me = `${person.firstName[0]}. ${person.lastName}`;
</div>
<div class="papers">
{publications.map(({ venue, title, authors, url }) => (
<a class="paper reveal" href={url} rel="noopener">
<a class="paper reveal" href={url} rel="noopener" target="_blank">
<span class="venue">{venue}</span>
<h3>{title}</h3>
<p>
Expand All @@ -28,6 +28,7 @@ const me = `${person.firstName[0]}. ${person.lastName}`;
))}
</p>
<span class="more">Read the paper →</span>
<span class="sr-only"> (opens in a new tab)</span>
</a>
))}
</div>
Expand All @@ -36,7 +37,12 @@ const me = `${person.firstName[0]}. ${person.lastName}`;
<li class="reveal">
<b>{title}</b>
<span>{text}</span>
{url && <a href={url}>{link} →</a>}
{/* A paper or a PDF opens beside the site rather than replacing it. */}
{url && (
<a href={url} rel="noopener" target="_blank">
{link} →<span class="sr-only"> (opens in a new tab)</span>
</a>
)}
</li>
))}
</ul>
Expand Down
35 changes: 35 additions & 0 deletions tests/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ test('serves the CV, the thesis and the icons', async ({ request }) => {
}
});

test('opens other sites and read-only PDFs in a new tab, and says so', async ({ page }) => {
await page.goto('/');
const leaving = page.locator('a[href^="http"], a[href$=".pdf"]:not([download])');
expect(await leaving.count()).toBeGreaterThan(4);
for (const link of await leaving.all()) {
const href = await link.getAttribute('href');
await expect(link, href!).toHaveAttribute('target', '_blank');
await expect(link, href!).toHaveAttribute('rel', /noopener/);
await expect(link.locator('.sr-only'), href!).toHaveText('(opens in a new tab)');
}
// Downloads stay where they are.
for (const link of await page.locator('a[download]').all()) {
await expect(link).not.toHaveAttribute('target', '_blank');
}
});

test('tells search engines and link previews who the page is about', async ({ page, request }) => {
await page.goto('/');
const meta = (selector: string) => page.locator(selector).getAttribute('content');
Expand Down Expand Up @@ -261,6 +277,25 @@ test("paints past the page's edges in the theme's colour, then the footer's", as
await expect(edge).toBeHidden();
});

test('copies the email address in one click, and says so', async ({ page }) => {
// A stand-in clipboard, the same in every browser, that records what it is given.
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async (text: string) => ((window as any).copied = text) },
});
});
await page.goto('/');

const copy = page.getByRole('button', { name: /Copy the email address/ });
await copy.click();
expect(await page.evaluate(() => (window as any).copied)).toBe('terencio.agozzino@gmail.com');
await expect(copy.getByText('Copied')).toBeVisible();
await expect(page.locator('.address .status')).toHaveText('Email address copied');

// Back to the icon after a moment.
await expect(copy.getByText('Copied')).toBeHidden({ timeout: 3000 });
});

test('counts the visit, and the CV, the thesis, an email and a link out', async ({ page }) => {
// A stand-in for GoatCounter's script, which records what it is given.
await page.route('https://gc.zgo.at/count.js', (route) =>
Expand Down
Loading