update design
This commit is contained in:
10 files changed
+1266
-835
No files matched your search
@@ -0,0 +1,344 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync, existsSync } = require('node:fs');
|
||||
const { resolve } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { runInNewContext } = require('node:vm');
|
||||
|
||||
const root = resolve(__dirname, '..');
|
||||
const script = readFileSync(resolve(root, 'script.js'), 'utf8');
|
||||
const html = readFileSync(resolve(root, 'index.html'), 'utf8');
|
||||
|
||||
function element(dataset = {}, textContent = '') {
|
||||
const classes = new Set();
|
||||
const attributes = new Map();
|
||||
const listeners = new Map();
|
||||
return {
|
||||
dataset,
|
||||
textContent,
|
||||
hidden: true,
|
||||
focused: false,
|
||||
classList: {
|
||||
add: (name) => classes.add(name),
|
||||
contains: (name) => classes.has(name),
|
||||
toggle: (name, force) => {
|
||||
if (force) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
getAttribute: (name) => attributes.get(name),
|
||||
removeAttribute: (name) => attributes.delete(name),
|
||||
addEventListener: (name, listener) => listeners.set(name, listener),
|
||||
click: () => listeners.get('click')(),
|
||||
dispatch: (name, event) => listeners.get(name)(event),
|
||||
focus() {
|
||||
this.focused = true;
|
||||
},
|
||||
scrollIntoView() {
|
||||
this.scrolledIntoView = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture({ missingFilter = false, reducedMotion = false } = {}) {
|
||||
const chips = Array.from(
|
||||
html.matchAll(
|
||||
/<button\b[^>]*data-filter="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g
|
||||
),
|
||||
(match) => element({ filter: match[1] }, match[2].trim())
|
||||
);
|
||||
if (missingFilter) chips.push(element({ filter: 'missing' }, 'Missing'));
|
||||
const cards = Array.from(
|
||||
html.matchAll(
|
||||
/<article\s+class="card project-card"\s+data-tech="([^"]+)"/g
|
||||
),
|
||||
(match) => element({ tech: match[1] })
|
||||
);
|
||||
const controls = new Map(
|
||||
[
|
||||
'#projects-see-more',
|
||||
'#project-count',
|
||||
'#projects-empty',
|
||||
'#project-grid',
|
||||
'.project-filters',
|
||||
'#projects-reset',
|
||||
].map((selector) => [selector, element()])
|
||||
);
|
||||
const reveal = element();
|
||||
const observers = [];
|
||||
class IntersectionObserver {
|
||||
constructor(callback, options) {
|
||||
this.callback = callback;
|
||||
this.options = options;
|
||||
this.targets = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target) {
|
||||
this.targets.push(target);
|
||||
}
|
||||
unobserve(target) {
|
||||
this.targets = this.targets.filter((item) => item !== target);
|
||||
}
|
||||
disconnect() {
|
||||
this.targets = [];
|
||||
this.disconnected = true;
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
document: {
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector === '.filter-chip') return chips;
|
||||
if (selector === '#project-grid .project-card') return cards;
|
||||
if (selector === '.reveal') return [reveal];
|
||||
return [];
|
||||
},
|
||||
querySelector: (selector) => controls.get(selector) || null,
|
||||
},
|
||||
window: {
|
||||
IntersectionObserver,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
},
|
||||
IntersectionObserver,
|
||||
};
|
||||
return {
|
||||
chips,
|
||||
cards,
|
||||
controls,
|
||||
reveal,
|
||||
observers,
|
||||
context,
|
||||
start: () => runInNewContext(script, context),
|
||||
visible: () => cards.filter((card) => !card.hidden),
|
||||
};
|
||||
}
|
||||
|
||||
test('projects start collapsed with an accurate count and remaining label', () => {
|
||||
const f = fixture();
|
||||
f.start();
|
||||
assert.equal(f.visible().length, 9);
|
||||
assert.equal(
|
||||
f.controls.get('#project-count').textContent,
|
||||
`Showing 9 of ${f.cards.length} projects`
|
||||
);
|
||||
assert.equal(
|
||||
f.controls.get('#projects-see-more').textContent,
|
||||
`Show ${f.cards.length - 9} more projects`
|
||||
);
|
||||
assert.equal(f.controls.get('.project-filters').hidden, false);
|
||||
assert.equal(f.controls.get('#projects-empty').hidden, true);
|
||||
assert.equal(f.chips[0].getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
test('expanding and collapsing updates the count and keeps the control in view', () => {
|
||||
const f = fixture();
|
||||
f.start();
|
||||
const button = f.controls.get('#projects-see-more');
|
||||
button.click();
|
||||
assert.equal(f.visible().length, f.cards.length);
|
||||
assert.equal(button.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(button.textContent, 'Show fewer projects');
|
||||
button.click();
|
||||
assert.equal(f.visible().length, 9);
|
||||
assert.equal(button.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(button.scrolledIntoView, true);
|
||||
});
|
||||
|
||||
test('every technology filter selects matching cards and resets expansion', () => {
|
||||
const f = fixture();
|
||||
f.start();
|
||||
for (const chip of f.chips) {
|
||||
f.controls.get('#projects-see-more').click();
|
||||
chip.click();
|
||||
const expected = f.cards.filter(
|
||||
(card) =>
|
||||
chip.dataset.filter === 'all' ||
|
||||
card.dataset.tech.split(/\s+/).includes(chip.dataset.filter)
|
||||
);
|
||||
assert.deepEqual(f.visible(), expected.slice(0, 9));
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(
|
||||
f.chips.filter((item) => item.getAttribute('aria-pressed') === 'true')
|
||||
.length,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
f.controls.get('#projects-see-more').hidden,
|
||||
expected.length <= 9
|
||||
);
|
||||
assert.equal(
|
||||
f.controls.get('#projects-see-more').getAttribute('aria-expanded'),
|
||||
'false'
|
||||
);
|
||||
assert.equal(
|
||||
f.controls.get('#project-count').textContent,
|
||||
`Showing ${Math.min(9, expected.length)} of ${expected.length} project${expected.length === 1 ? '' : 's'}` +
|
||||
(chip.dataset.filter === 'all' ? '' : ` (${chip.textContent})`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty results show a reset which restores all projects and focus', () => {
|
||||
const f = fixture({ missingFilter: true });
|
||||
f.start();
|
||||
f.chips.at(-1).click();
|
||||
assert.equal(f.visible().length, 0);
|
||||
assert.equal(f.controls.get('#projects-empty').hidden, false);
|
||||
assert.equal(f.controls.get('#project-grid').hidden, true);
|
||||
assert.equal(f.controls.get('#projects-see-more').hidden, true);
|
||||
f.controls.get('#projects-reset').click();
|
||||
assert.equal(f.visible().length, 9);
|
||||
assert.equal(f.controls.get('#projects-empty').hidden, true);
|
||||
assert.equal(f.controls.get('#project-grid').hidden, false);
|
||||
assert.equal(f.chips[0].focused, true);
|
||||
});
|
||||
|
||||
test('reveals only become pending when an observer can reveal them', () => {
|
||||
const f = fixture();
|
||||
f.start();
|
||||
assert.equal(f.reveal.classList.contains('reveal-pending'), true);
|
||||
const observer = f.observers[0];
|
||||
observer.callback([{ target: f.reveal, isIntersecting: true }], observer);
|
||||
assert.equal(f.reveal.classList.contains('visible'), true);
|
||||
assert.equal(observer.targets.length, 0);
|
||||
|
||||
const reduced = fixture({ reducedMotion: true });
|
||||
reduced.start();
|
||||
assert.equal(reduced.reveal.classList.contains('reveal-pending'), false);
|
||||
|
||||
const unsupported = fixture();
|
||||
delete unsupported.context.window.IntersectionObserver;
|
||||
unsupported.start();
|
||||
assert.equal(unsupported.reveal.classList.contains('reveal-pending'), false);
|
||||
assert.equal(unsupported.controls.get('.project-filters').hidden, false);
|
||||
});
|
||||
|
||||
test('active navigation uses a viewport-sized observer and updates on resize', () => {
|
||||
const f = fixture();
|
||||
const sections = ['hero', 'about', 'skills', 'projects', 'contact'].map(
|
||||
(id) => ({ ...element(), id })
|
||||
);
|
||||
const links = sections.slice(1).map((section) => {
|
||||
const link = element();
|
||||
link.setAttribute('href', `#${section.id}`);
|
||||
return link;
|
||||
});
|
||||
const query = f.context.document.querySelectorAll;
|
||||
f.context.document.querySelectorAll = (selector) => {
|
||||
if (selector === 'section[id]') return sections;
|
||||
if (selector === '.nav-links a, .mobile-nav-links a') return links;
|
||||
return query(selector);
|
||||
};
|
||||
const listeners = new Map();
|
||||
f.context.window.innerHeight = 900;
|
||||
f.context.window.addEventListener = (name, listener) =>
|
||||
listeners.set(name, listener);
|
||||
f.start();
|
||||
const observer = f.observers[0];
|
||||
assert.equal(observer.options.rootMargin, '-56px 0px -740px 0px');
|
||||
observer.callback([{ target: sections[1], isIntersecting: true }]);
|
||||
assert.equal(links[0].getAttribute('aria-current'), 'location');
|
||||
observer.callback([
|
||||
{ target: sections[1], isIntersecting: false },
|
||||
{ target: sections[2], isIntersecting: true },
|
||||
]);
|
||||
assert.equal(links[0].getAttribute('aria-current'), undefined);
|
||||
assert.equal(links[1].getAttribute('aria-current'), 'location');
|
||||
f.context.window.innerHeight = 600;
|
||||
listeners.get('resize')();
|
||||
assert.equal(observer.disconnected, true);
|
||||
assert.equal(f.observers.at(-1).options.rootMargin, '-56px 0px -440px 0px');
|
||||
assert.deepEqual(f.observers.at(-1).targets, sections);
|
||||
});
|
||||
|
||||
test('mobile navigation closes after selection and Escape restores menu focus', () => {
|
||||
const f = fixture();
|
||||
const menu = element();
|
||||
const summary = element();
|
||||
const link = element();
|
||||
menu.querySelectorAll = () => [link];
|
||||
menu.querySelector = () => summary;
|
||||
f.controls.set('.nav-menu', menu);
|
||||
f.start();
|
||||
menu.open = true;
|
||||
link.click();
|
||||
assert.equal(menu.open, false);
|
||||
menu.open = true;
|
||||
menu.dispatch('keydown', { key: 'Escape' });
|
||||
assert.equal(menu.open, false);
|
||||
assert.equal(summary.focused, true);
|
||||
});
|
||||
|
||||
test('all project destinations are native links with existing local files', () => {
|
||||
assert.doesNotMatch(html, /data-href=|class="[^"]*card-link/);
|
||||
const links = Array.from(
|
||||
html.matchAll(/<a class="card-title" href="([^"]+)"/g),
|
||||
(match) => match[1]
|
||||
);
|
||||
assert.equal(links.length, 17);
|
||||
for (const href of links) {
|
||||
if (!href.startsWith('https://')) {
|
||||
assert.ok(existsSync(resolve(root, href)), `Missing project: ${href}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the hero is text-only and featured projects have local screenshots', () => {
|
||||
const hero = html.match(/<section id="hero">([\s\S]*?)<\/section>/)[1];
|
||||
assert.doesNotMatch(hero, /<img|<figure|flocking/i);
|
||||
const previews = Array.from(
|
||||
html.matchAll(/<img\s+class="project-preview"[\s\S]*?\/>/g),
|
||||
(match) => match[0]
|
||||
);
|
||||
assert.equal(previews.length, 2);
|
||||
for (const preview of previews) {
|
||||
const src = preview.match(/src="([^"]+)"/)[1];
|
||||
assert.ok(existsSync(resolve(root, src)));
|
||||
assert.match(preview, /loading="lazy"/);
|
||||
assert.match(preview, /width="1200"/);
|
||||
assert.match(preview, /height="800"/);
|
||||
}
|
||||
});
|
||||
|
||||
test('LinkedIn is the first and primary contact action, with GitHub secondary', () => {
|
||||
const contact = html.match(/<section id="contact">([\s\S]*?)<\/section>/)[1];
|
||||
const links = Array.from(
|
||||
contact.matchAll(/<a\s+href="([^"]+)"\s+class="([^"]+)"/g)
|
||||
);
|
||||
assert.equal(links.length, 2);
|
||||
assert.equal(links[0][1], 'https://www.linkedin.com/in/jaygatward');
|
||||
assert.match(links[0][2], /btn-primary/);
|
||||
assert.equal(links[1][1], 'https://github.com/jayo60013');
|
||||
assert.doesNotMatch(links[1][2], /btn-primary/);
|
||||
assert.match(contact, /Connect on LinkedIn/);
|
||||
assert.match(contact, /Explore my code/);
|
||||
});
|
||||
|
||||
test('About has a genuine local portrait and an unboxed opening paragraph', () => {
|
||||
const about = html.match(/<section id="about"[^>]*>([\s\S]*?)<\/section>/)[1];
|
||||
assert.match(about, /class="about-intro"/);
|
||||
assert.doesNotMatch(about, /about-grid|about-block-tag/);
|
||||
const portrait = about.match(/<img\s+class="about-portrait"[\s\S]*?\/>/)[0];
|
||||
assert.match(portrait, /alt="John Gatward"/);
|
||||
assert.match(portrait, /width="800"[\s\S]*height="800"/);
|
||||
assert.ok(existsSync(resolve(root, portrait.match(/src="([^"]+)"/)[1])));
|
||||
assert.match(about, /<h3 class="about-subheading">Version history<\/h3>/);
|
||||
assert.equal(
|
||||
(about.match(/href="archive\/v[123]\/index.html"/g) || []).length,
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
test('featured projects put title and purpose ahead of supporting metadata', () => {
|
||||
const featured = Array.from(
|
||||
html.matchAll(/<article class="card featured-card">([\s\S]*?)<\/article>/g),
|
||||
(match) => match[1]
|
||||
);
|
||||
assert.equal(featured.length, 2);
|
||||
for (const card of featured) {
|
||||
assert.ok(card.indexOf('project-preview') < card.indexOf('card-title'));
|
||||
assert.ok(card.indexOf('card-title') < card.indexOf('card-desc'));
|
||||
assert.ok(card.indexOf('card-desc') < card.indexOf('project-meta'));
|
||||
assert.ok(card.indexOf('project-meta') < card.indexOf('tag-row'));
|
||||
}
|
||||
assert.doesNotMatch(html, /class="section-label|class="divider"/);
|
||||
});
|
||||
Reference in new issue
Block a user