From 04b4de9d776454e180672c965049a5f1ec1c83d8 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 14:52:14 -0800 Subject: [PATCH 01/31] feature: render catalog-driven bands for MP-103 --- ISSUES.md | 4 +- data/projects.json | 130 ++++++++++++ index.html | 174 +++------------- script.js | 491 +++++++++++++-------------------------------- styles.css | 243 ++++++++++++++-------- tests/hero.spec.js | 34 +++- 6 files changed, 491 insertions(+), 585 deletions(-) create mode 100644 data/projects.json diff --git a/ISSUES.md b/ISSUES.md index a26c98b..c3c6af0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -12,7 +12,7 @@ Each issue is formatted as `- [ ] [-]`. When resolved it becomes -` - [x] [MP-100] Replaced the sketch-based hero with the inline hero video (muted by default with an audio toggle) and refreshed the favicon using the provided JPEG source. - [x] [MP-101] Changed the global theme to dark turquoise with golden typography, updating hero/sections/cards/buttons accordingly. - [x] [MP-102] Integrated the declarative `` from mpr-ui (non-sticky, no theme switch) plus lab-wide quick links. -- [ ] [MP-103] Restructure landing page into four product bands +- [x] [MP-103] Restructure landing page into four product bands Summary - Rebuild the entire landing page information architecture (below the hero) around four top-level sections: Research, Tools, Platform, Products. @@ -59,6 +59,8 @@ Each issue is formatted as `- [ ] [-]`. When resolved it becomes -` - Presence of section headings “Research”, “Tools”, “Platform”, “Products”. - At least one project card rendered under each of those headings, driven by the JSON catalog. - Every rendered card shows a name, description, and status badge, and either a working link or an explicit “coming soon” style when configured. + Outcome + - Created `data/projects.json`, rebuilt the four section bands to render cards from the catalog (status-sorted with static visuals), and added Playwright assertions for headings, cards, and link behavior. ## Improvements (200–299) diff --git a/data/projects.json b/data/projects.json new file mode 100644 index 0000000..f727594 --- /dev/null +++ b/data/projects.json @@ -0,0 +1,130 @@ +{ + "projects": [ + { + "id": "issues-md", + "name": "ISSUES.md", + "description": "Append-only lab worklog that tracks features, improvements, and maintenance activity across Marco Polo Research Lab projects.", + "status": "WIP", + "category": "Research", + "url": "https://github.com/MarcoPoloResearchLab/marcopolo.github.io/blob/main/ISSUES.md", + "public": true + }, + { + "id": "photolab", + "name": "Photolab", + "description": "Local photo library classifier and search UI that writes high-confidence labels into EXIF, indexes metadata into SQLite, and serves a minimal browser-based search grid.", + "status": "WIP", + "category": "Research", + "url": null, + "public": false + }, + { + "id": "ctx", + "name": "ctx", + "description": "Terminal-first project explorer for browsing trees, reading files with embedded docs, analysing call chains, and fetching upstream docs from GitHub via one CLI.", + "status": "Production", + "category": "Tools", + "url": "https://github.com/tyemirov/ctx", + "public": true + }, + { + "id": "gix", + "name": "gix", + "description": "Git and GitHub maintenance CLI for keeping large fleets of repositories healthy by normalising folder names, aligning remotes, and automating audit/release workflows.", + "status": "Production", + "category": "Tools", + "url": "https://github.com/tyemirov/gix", + "public": true + }, + { + "id": "ghttp", + "name": "gHTTP", + "description": "Go-powered static file server that mirrors python -m http.server while adding Markdown rendering, structured logging, and easy HTTPS provisioning for local work or containers.", + "status": "Production", + "category": "Tools", + "url": "https://github.com/temirov/ghttp", + "public": true + }, + { + "id": "loopaware", + "name": "LoopAware", + "description": "Customer feedback platform with an embeddable widget, Google-authenticated dashboard, and APIs for collecting, triaging, and responding to product messages.", + "status": "Production", + "category": "Platform", + "url": "https://loopaware.mprlab.com", + "public": true + }, + { + "id": "pinguin", + "name": "Pinguin", + "description": "Production-ready notification service that exposes a gRPC API for email and SMS, persists jobs in SQLite, and retries failures with an exponential-backoff scheduler.", + "status": "Production", + "category": "Platform", + "url": "https://github.com/temirov/pinguin", + "public": true + }, + { + "id": "ets", + "name": "Ephemeral Token Service (ETS)", + "description": "JWT + DPoP gateway that mints short-lived, browser-bound access tokens and reverse-proxies requests so front-end apps never handle provider secrets directly.", + "status": "Beta", + "category": "Platform", + "url": "https://ets.mprlab.com", + "public": true + }, + { + "id": "tauth", + "name": "TAuth", + "description": "Google Sign-In and session service that verifies ID tokens, issues short-lived JWT cookies, and ships a tiny auth-client.js helper for same-origin apps.", + "status": "Production", + "category": "Platform", + "url": "https://tauth.mprlab.com", + "public": true + }, + { + "id": "ledger", + "name": "Ledger Service", + "description": "Standalone gRPC-based virtual credits ledger that tracks grants, reservations, captures, and releases in an append-only store backed by SQL with full auditability.", + "status": "Beta", + "category": "Platform", + "url": "https://github.com/tyemirov/ledger", + "public": true + }, + { + "id": "product-scanner", + "name": "Product Scanner", + "description": "Product detail page auditor that crawls listings, evaluates them against configurable rule packs, and reports gaps through a CLI and authenticated dashboard.", + "status": "Beta", + "category": "Products", + "url": "https://ps.mprlab.com", + "public": true + }, + { + "id": "sheet2tube", + "name": "Sheet2Tube", + "description": "CSV and web toolkit that round-trips YouTube channel metadata between spreadsheets and your account plus a GPT-powered helper for expanding scripted placeholders.", + "status": "Beta", + "category": "Products", + "url": "https://sheet2tube.mprlab.com", + "public": true + }, + { + "id": "gravity-notes", + "name": "Gravity Notes", + "description": "Single-page Markdown notebook with an inline card grid, offline-first storage, and Google-backed sync so ideas flow without modal dialogs or context switches.", + "status": "Production", + "category": "Products", + "url": "https://gravity.mprlab.com", + "public": true + }, + { + "id": "rsvp", + "name": "RSVP", + "description": "Event invitation platform that generates QR-code-powered invites, tracks responses, and supports both local and production TLS setups for secure guest flows.", + "status": "Production", + "category": "Products", + "url": "https://rsvp.mprlab.com", + "public": true + } + ] +} diff --git a/index.html b/index.html index b96340c..6d7496c 100644 --- a/index.html +++ b/index.html @@ -100,161 +100,36 @@

Marco Polo Research Lab

- -
- -
-

Our Mission

-

- At Marco Polo Research Lab, we build exceptional products that are - simple, reliable, and help people get things done. We create - intuitive, productive tools, avoiding unnecessary complexity. We - listen to user feedback and never stop tweaking and tuning. -

+ +
+
+
+

Research

+
+
- -
-

Discoveries & Creations

-
-
-
- -
-
-

Social Threader

-

- A web-based tool that intelligently breaks long text into - smaller chunks for social media platforms. -

- Launch App -
-
-
-
- -
-
-

Countdown Calendar

-

A sleek calendar tool that counts down to designated dates.

- Launch App -
-
-
-
- -
-
-

Nearest City Finder

-

- CLI and web application that finds the closest city by driving - distance. -

- View on GitHub -
-
-
-
- -
-
-

RSVP

-

- An events invitation platform that relies on physical QR Codes - and allows printing, sending, and tracking invitations to - events. -

- Launch App -
-
-
-
- -
-
-

LLM Crossword

-

- Dynamically built crosswords. Enter the topic and get a - crossword created just for you. -

- Launch App -
-
-
-
- -
-
-

Old Millionaire

-

- How much money you’d need **today** to match the purchasing - power of \$1,000,000 in your birth year. -

- Launch App -
-
-
-
- -
-
-

Allergy Wheel

-

- Interactive allergy-learning game where kids pick food - allergens, spin the wheel to win hearts, and discover dishes - that match their choices. -

- Launch App -
-
+
+
+

Tools

+
- -
-

Chart Your Course With Us

-

- Ready to embark on your next project or have a question? Reach out and - let's explore the possibilities. -

-

Email: contact@mprlab.com

+
+
+

Platform

+
+
+
+ +
+
+

Products

+
+
-
+
Chart Your Course With Us > - diff --git a/script.js b/script.js index 6ce7aad..f837915 100644 --- a/script.js +++ b/script.js @@ -1,365 +1,164 @@ -/* ---------- asset paths ---------- */ -const SOCIAL_THREADER_SVG = "assets/social_threader.svg"; -const COUNTDOWN_CALENDAR_SVG = "assets/countdown_calendar.svg"; -const CITY_FINDER_SVG = "assets/city_finder.svg"; -const RSVP_SVG = "assets/rsvp.svg"; -const LLM_CROSSWORD_SVG = "assets/llm_crossword.svg"; -const OLD_MILLIONAIRE_SVG = "assets/old_millionaire.svg"; -const ALLERGY_WHEEL_SVG = "assets/allergy_wheel.svg"; -/* ---------- visual tuning ---------- */ -const PROJECT_SCALE = 1.5; -const PROJECT_X_SCALE_FACTOR = 0.80; -const PROJECT_Y_SCALE_FACTOR = 0.30; - -/* ---------- timing ---------- */ -const PROJECT_DRAW_SECONDS = 3; -const FPS = 60; -const PROJECT_TOTAL_FRAMES = PROJECT_DRAW_SECONDS * FPS; - -/* ---------- stroke ---------- */ -const COLOR = 0xffd369; -const WIDTH = 1.2; - -/* ---------- project animations ---------- */ -const projectAnimations = new Map(); -const PROJECT_CANVASES = [ - {id: "social-threader-canvas", svg: SOCIAL_THREADER_SVG}, - {id: "countdown-calendar-canvas", svg: COUNTDOWN_CALENDAR_SVG}, - {id: "city-finder-canvas", svg: CITY_FINDER_SVG}, - {id: "rsvp-canvas", svg: RSVP_SVG}, - {id: "llm-crossword-canvas", svg: LLM_CROSSWORD_SVG}, - {id: "old-millionaire-canvas", svg: OLD_MILLIONAIRE_SVG}, - {id: "allergy-wheel-canvas", svg: ALLERGY_WHEEL_SVG}, -]; - -/* ───── SVG parsing ───── */ -function parsePath(d, W, H, scale) { - const pts = []; - let cp = {x: 0, y: 0}, sp = {x: 0, y: 0}; - const cmds = d.match(/[a-zA-Z][^a-zA-Z]*/g) || []; - const abs = (v, i, rel) => (rel ? (i % 2 ? cp.y + v : cp.x + v) : v); - const push = p => pts.push({ - x: (p.x / W - 0.5) * scale, - y: (0.5 - p.y / H) * scale - }); +// @ts-check + +/** + * @typedef {"Research" | "Tools" | "Platform" | "Products"} ProjectCategory + * @typedef {"Production" | "Beta" | "WIP"} ProjectStatus + * + * @typedef {Object} Project + * @property {string} id + * @property {string} name + * @property {string} description + * @property {ProjectStatus} status + * @property {ProjectCategory} category + * @property {string | null} url + * @property {boolean} [public] + */ + +const SECTION_ORDER = /** @type {ProjectCategory[]} */ ([ + "Research", + "Tools", + "Platform", + "Products" +]); + +const STATUS_PRIORITY = Object.freeze({ + Production: 0, + Beta: 1, + WIP: 2 +}); - for (const s of cmds) { - const c = s[0], C = c.toUpperCase(), rel = c !== C; - // Extract numeric tokens (including negatives and decimals) from the - // command string. Regex breakdown: - // [-+]? -> optional sign - // (?:\d*\.\d+|\d+) -> decimal numbers with optional leading digits or integers - const v = (s.slice(1).trim().match(/[-+]?(?:\d*\.\d+|\d+)/g) || []) - .map(parseFloat); - let i = 0; +const STATUS_CLASS = Object.freeze({ + Production: "status-badge-production", + Beta: "status-badge-beta", + WIP: "status-badge-wip" +}); - switch (C) { - case "M": - case "L": - for (; i < v.length; i += 2) { - cp.x = abs(v[i], 0, rel); - cp.y = abs(v[i + 1], 1, rel); - if (C === "M" && i === 0) sp = {...cp}; - push(cp); - } - break; - case "H": - v.forEach(e => { - cp.x = rel ? cp.x + e : e; - push(cp); - }); - break; - case "V": - v.forEach(e => { - cp.y = rel ? cp.y + e : e; - push(cp); - }); - break; - case "C": - while (i + 5 < v.length) { - const p0 = {...cp}; - const p1 = {x: abs(v[i], 0, rel), y: abs(v[i + 1], 1, rel)}; - const p2 = {x: abs(v[i + 2], 0, rel), y: abs(v[i + 3], 1, rel)}; - const p3 = {x: abs(v[i + 4], 0, rel), y: abs(v[i + 5], 1, rel)}; - for (let k = 1; k <= 12; k++) { - const t = k / 12, it = 1 - t; - push({ - x: it * it * it * p0.x + 3 * it * it * t * p1.x + 3 * it * t * t * p2.x + t * t * t * p3.x, - y: it * it * it * p0.y + 3 * it * it * t * p1.y + 3 * it * t * t * p2.y + t * t * t * p3.y - }); - } - cp = {...p3}; - i += 6; - } - break; - case "Q": - while (i + 3 < v.length) { - const p0 = {...cp}; - const p1 = {x: abs(v[i], 0, rel), y: abs(v[i + 1], 1, rel)}; - const p2 = {x: abs(v[i + 2], 0, rel), y: abs(v[i + 3], 1, rel)}; - for (let k = 1; k <= 10; k++) { - const t = k / 10, it = 1 - t; - push({ - x: it * it * p0.x + 2 * it * t * p1.x + t * t * p2.x, - y: it * it * p0.y + 2 * it * t * p1.y + t * t * p2.y - }); - } - cp = {...p2}; - i += 4; - } - break; - case "Z": - push(sp); - cp = {...sp}; - break; - } +/** + * Fetches the JSON catalog for the landing page. + * @returns {Promise} + */ +async function loadProjectCatalog() { + const response = await fetch("data/projects.json", {cache: "no-store"}); + if (!response.ok) { + throw new Error(`projects.json: ${response.status}`); } - return pts; -} -async function loadSVG(url, scale) { - try { - const res = await fetch(url); - if (!res.ok) throw new Error(`${url}: ${res.status}`); - - const svg = new DOMParser() - .parseFromString(await res.text(), "image/svg+xml") - .documentElement; - - const vb = (svg.getAttribute("viewBox") || "0 0 300 300") - .split(/[ ,]+/).map(Number); - - const W = parseFloat(svg.getAttribute("width")) || vb[2]; - const H = parseFloat(svg.getAttribute("height")) || vb[3]; - - return [...svg.querySelectorAll("path")] - .map(p => parsePath(p.getAttribute("d") || "", W, H, scale)) - .filter(s => s.length > 1); - } catch (err) { - console.error(`Error loading SVG from ${url}:`, err); - return []; + /** @type {{projects?: Project[]}} */ + const payload = await response.json(); + if (!Array.isArray(payload.projects)) { + throw new Error("projects.json missing projects array"); } + return payload.projects; } -/* ───── THREE.js helpers ───── */ -function makeLines(segs, targetScene) { - const mat = new THREE.LineBasicMaterial({color: COLOR, linewidth: WIDTH}); - return segs.map(seg => { - const geo = new THREE.BufferGeometry(); - geo.setAttribute( - "position", - new THREE.BufferAttribute(new Float32Array(seg.length * 3), 3) - ); - geo.setDrawRange(0, 0); - const line = new THREE.Line(geo, mat); - line.userData = {pts: seg, drawn: 0}; - targetScene.add(line); - return line; - }); -} - -function draw(lines, count) { - let left = count; - for (const l of lines) { - const {pts, drawn} = l.userData; - if (drawn >= pts.length) continue; - - const n = Math.min(left, pts.length - drawn); - const pos = l.geometry.attributes.position; - - for (let i = 0; i < n; i++) { - const p = pts[drawn + i]; - pos.setXYZ(drawn + i, p.x, p.y, 0); - } - - l.userData.drawn += n; - pos.needsUpdate = true; - l.geometry.setDrawRange(0, l.userData.drawn); - - left -= n; - if (!left) break; +/** + * Creates semantic markup for a project card. + * @param {Project} project + * @returns {HTMLElement} + */ +function buildProjectCard(project) { + const card = document.createElement("article"); + card.className = "project-card"; + card.dataset.status = project.status.toLowerCase(); + + const visual = document.createElement("div"); + visual.className = "project-card-visual"; + visual.textContent = deriveMonogram(project.name); + + const title = document.createElement("h3"); + title.textContent = project.name; + + const titleGroup = document.createElement("div"); + titleGroup.className = "project-card-title"; + titleGroup.append(visual, title); + + const header = document.createElement("div"); + header.className = "project-card-header"; + header.append(titleGroup, buildStatusBadge(project.status)); + + const body = document.createElement("div"); + body.className = "card-body"; + + const description = document.createElement("p"); + description.textContent = project.description; + body.append(description); + + if (project.url && project.status !== "WIP") { + const link = document.createElement("a"); + link.href = project.url; + link.className = "card-action"; + link.target = "_blank"; + link.rel = "noreferrer noopener"; + link.textContent = project.status === "Production" ? "Launch product" : "Explore beta"; + body.append(link); } -} - -/* ───── Layout helpers ───── */ -function projectWorldHeight(projectCamera) { - return 2 * projectCamera.position.z * Math.tan(THREE.MathUtils.degToRad(projectCamera.fov / 2)); -} -function projectWorldWidth(projectCamera) { - const h = projectWorldHeight(projectCamera); - return h * projectCamera.aspect; + card.append(header, body); + return card; } -function positionProjectSegments(segments, projectCamera) { - const allPoints = segments.flatMap(s => s); - if (!allPoints.length) return; - - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - allPoints.forEach(p => { - if (p.x < minX) minX = p.x; - if (p.x > maxX) maxX = p.x; - if (p.y < minY) minY = p.y; - if (p.y > maxY) maxY = p.y; - }); - - const initialWidth = maxX - minX; - const initialHeight = maxY - minY; - const initialCenterX = (minX + maxX) / 2; - const initialCenterY = (minY + maxY) / 2; - - const wWidth = projectWorldWidth(projectCamera); - const wHeight = projectWorldHeight(projectCamera); - - const targetWidth = wWidth * PROJECT_X_SCALE_FACTOR; - const targetHeight = wHeight * PROJECT_Y_SCALE_FACTOR; - - let xScaleFactor = 1; - if (initialWidth > 0.0001) { - xScaleFactor = targetWidth / initialWidth; - } - - let yScaleFactor = 1; - if (initialHeight > 0.0001) { - yScaleFactor = targetHeight / initialHeight; - } - - segments.forEach(seg => - seg.forEach(p => { - p.x = (p.x - initialCenterX) * xScaleFactor; - p.y = (p.y - initialCenterY) * yScaleFactor; - }) - ); +/** + * Builds a badge element scoped to the project status. + * @param {ProjectStatus} status + * @returns {HTMLElement} + */ +function buildStatusBadge(status) { + const badge = document.createElement("span"); + badge.className = "status-badge"; + const modifier = STATUS_CLASS[status]; + if (modifier) badge.classList.add(modifier); + badge.textContent = status; + return badge; } -/* ───── Project Animation Setup ───── */ -async function initProjectAnimation(canvasId, svgPath) { - const canvas = document.getElementById(canvasId); - if (!canvas) return; - - const container = canvas.parentElement; - - const projectScene = new THREE.Scene(); - const projectCamera = new THREE.PerspectiveCamera( - 75, - container.offsetWidth / container.offsetHeight, - 0.1, - 100 - ); - projectCamera.position.z = 4; - - const projectRenderer = new THREE.WebGLRenderer({ - canvas: canvas, - alpha: true, - antialias: true - }); - projectRenderer.setPixelRatio(window.devicePixelRatio); - projectRenderer.setSize(container.offsetWidth, container.offsetHeight); - - const loadedSegments = await loadSVG(svgPath, PROJECT_SCALE); - if (loadedSegments.length === 0) return; - - const segmentsTemp = JSON.parse(JSON.stringify(loadedSegments)); - positionProjectSegments(loadedSegments, projectCamera); - - const projectLines = makeLines(loadedSegments, projectScene); - const projectTotalPts = loadedSegments.reduce((s, a) => s + a.length, 0); - - projectAnimations.set(canvasId, { - scene: projectScene, - camera: projectCamera, - renderer: projectRenderer, - lines: projectLines, - totalPts: projectTotalPts, - frameCount: 0, - drawn: 0, - isAnimating: false, - hasAnimated: false, // Add this flag - segments: loadedSegments, - segmentsTemp: segmentsTemp - }); - - const resizeHandler = () => { - projectCamera.aspect = container.offsetWidth / container.offsetHeight; - projectCamera.updateProjectionMatrix(); - projectRenderer.setSize(container.offsetWidth, container.offsetHeight); - - const animation = projectAnimations.get(canvasId); - if (animation && animation.segmentsTemp.length > 0) { - const freshSegments = JSON.parse(JSON.stringify(animation.segmentsTemp)); - positionProjectSegments(freshSegments, projectCamera); - animation.lines.forEach((line, index) => { - const seg = freshSegments[index]; - const pos = line.geometry.attributes.position; - for (let i = 0; i < seg.length; i++) pos.setXYZ(i, seg[i].x, seg[i].y, 0); - pos.needsUpdate = true; - line.userData.pts = seg; - if (animation.frameCount > PROJECT_TOTAL_FRAMES) line.geometry.setDrawRange(0, seg.length); +/** + * Renders project cards inside each section band. + * @param {Project[]} projects + */ +function renderProjectBands(projects) { + SECTION_ORDER.forEach(category => { + const band = document.querySelector(`[data-band-category="${category}"]`); + if (!band) return; + const grid = band.querySelector("[data-band-cards]"); + if (!grid) return; + + grid.innerHTML = ""; + const scopedProjects = projects + .filter(project => project.category === category) + .sort((a, b) => { + const byStatus = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status]; + if (byStatus !== 0) return byStatus; + return a.name.localeCompare(b.name); }); - } - }; - - window.addEventListener("resize", resizeHandler); -} - -function startProjectAnimation(canvasId) { - const animation = projectAnimations.get(canvasId); - if (!animation || animation.isAnimating || animation.hasAnimated) return; // Check hasAnimated flag - animation.isAnimating = true; - animation.frameCount = 0; - animation.drawn = 0; - - animation.lines.forEach(line => { - line.userData.drawn = 0; - line.geometry.setDrawRange(0, 0); + scopedProjects.forEach(project => { + grid.append(buildProjectCard(project)); + }); }); +} - const animate = () => { - if (!animation.isAnimating) return; - - if (animation.frameCount <= PROJECT_TOTAL_FRAMES) { - const prog = animation.frameCount / PROJECT_TOTAL_FRAMES; - const ptsToDrawProject = Math.floor(prog * animation.totalPts); - - draw(animation.lines, ptsToDrawProject - animation.drawn); - animation.drawn = ptsToDrawProject; - animation.frameCount++; - - requestAnimationFrame(animate); - } else { - animation.isAnimating = false; - animation.hasAnimated = true; // Set flag when animation completes - } - - animation.renderer.render(animation.scene, animation.camera); - }; - - animate(); +/** + * Generates an uppercase monogram for the static icon block. + * @param {string} name + * @returns {string} + */ +function deriveMonogram(name) { + const initials = name + .split(/\s+/) + .filter(Boolean) + .map(part => part[0]) + .slice(0, 2) + .join(""); + return initials.toUpperCase() || name.slice(0, 2).toUpperCase(); } -/* ───── Initialization ───── */ -async function initProjectGallery() { +async function hydrateProjectCatalog() { try { - await Promise.all(PROJECT_CANVASES.map(({id, svg}) => initProjectAnimation(id, svg))); - } catch (err) { - console.error("Failed to prepare project animations:", err); - return; + const projects = await loadProjectCatalog(); + renderProjectBands(projects); + } catch (error) { + console.error("Failed to render project catalog:", error); } - - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - const canvasId = entry.target.id; - setTimeout(() => startProjectAnimation(canvasId), 0); - } - }); - }, {threshold: 0.1}); - - PROJECT_CANVASES.forEach(({id}) => { - const canvas = document.getElementById(id); - if (canvas) observer.observe(canvas); - }); } function setupHeroAudioToggle() { @@ -395,13 +194,7 @@ function setupHeroAudioToggle() { document.addEventListener("DOMContentLoaded", () => { setupHeroAudioToggle(); - - if (typeof THREE === "undefined") { - console.error("Three.js not loaded"); - return; - } - - initProjectGallery().catch(err => { - console.error("Failed to initialize project gallery:", err); + hydrateProjectCatalog().catch(error => { + console.error("Initialization error:", error); }); }); diff --git a/styles.css b/styles.css index ac752f5..c37bbea 100644 --- a/styles.css +++ b/styles.css @@ -193,119 +193,197 @@ body { display: inline; } -/* ---------- Sections ---------- */ -section:not(#top-content-section) { - background: var(--bg-panel); - padding: 50px 40px; - margin: 30px auto; - border-radius: 8px; +/* ---------- Project Bands ---------- */ +a { + color: var(--accent-gold); + text-decoration: none; + transition: color 0.3s ease; +} + +a:hover, +a:focus { + color: var(--text-gold-strong); +} + +.bands { + width: 100%; + box-sizing: border-box; + padding: 70px clamp(20px, 5vw, 80px) 110px; + display: flex; + flex-direction: column; + gap: 70px; +} + +.band { + position: relative; + border-radius: 40px; + padding: clamp(32px, 5vw, 72px); border: 1px solid var(--accent-outline); - box-shadow: 0 30px 60px rgba(0, 0, 0, 0.35); + box-shadow: 0 45px 120px rgba(0, 0, 0, 0.55); + overflow: hidden; } -h2 { - font-family: "Space Grotesk", sans-serif; - color: var(--text-gold); - margin: 0 0 40px; - text-align: center; - font-size: 2.2em; +.band::before { + content: ""; + position: absolute; + inset: 0; + opacity: 0.4; + background: radial-gradient(circle at top, rgba(255, 211, 105, 0.2), transparent 60%); + pointer-events: none; } -ul { - list-style: none; - padding: 0; +.band-research { + background: linear-gradient(125deg, #03222b, #06424d); } -li { - padding: 10px 0; - font-size: 1.1em; +.band-tools { + background: linear-gradient(125deg, #052b36, #08464d); } -a { - color: var(--accent-gold); - text-decoration: none; - transition: 0.3s; +.band-platform { + background: linear-gradient(125deg, #041f27, #0b3e46); } -a:hover { - color: var(--text-gold-strong); - text-decoration: underline; +.band-products { + background: linear-gradient(125deg, #06212b, #0c3641); +} + +.band-heading { + position: relative; + z-index: 1; + margin-bottom: 32px; +} + +.band-heading h2 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); + font-family: "Orbitron", "Space Grotesk", sans-serif; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-gold); + text-align: left; } -/* ---------- Project Grid ---------- */ -.project-grid { +.band-grid { + position: relative; + z-index: 1; display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 30px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 28px; } .project-card { - background: var(--bg-panel-alt); - border: 1px solid var(--accent-outline); - border-radius: 8px; + background: rgba(3, 27, 32, 0.85); + border-radius: 24px; + border: 1px solid rgba(248, 227, 154, 0.25); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55); + padding: 24px; display: flex; flex-direction: column; + gap: 18px; + position: relative; overflow: hidden; - transition: 0.3s; - box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); + min-height: 260px; + transition: transform 0.3s ease, border-color 0.3s ease; } .project-card:hover { - transform: translateY(-8px); - box-shadow: 0 25px 45px rgba(0, 0, 0, 0.55); + border-color: rgba(255, 221, 172, 0.55); + transform: translateY(-6px); } -.project-icon-block { - width: 100%; - height: 180px; +.project-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.project-card-title { + display: flex; + align-items: center; + gap: 14px; +} + +.project-card h3 { + margin: 0; + font-size: 1.4rem; + color: var(--text-gold); +} + +.project-card p { + margin: 0; + color: var(--text-gold-strong); + line-height: 1.5; +} + +.project-card-visual { + width: 58px; + height: 58px; + border-radius: 16px; + background: rgba(255, 211, 105, 0.12); + border: 1px solid rgba(255, 211, 105, 0.2); display: flex; align-items: center; justify-content: center; - background: rgba(255, 211, 105, 0.08); + font-size: 1.4rem; + font-weight: 600; + color: var(--accent-gold); +} + +.status-badge { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 999px; + padding: 0.35rem 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.15); position: relative; + overflow: hidden; + color: var(--text-gold-strong); + background: rgba(255, 255, 255, 0.08); } -.project-icon-block canvas { - width: 100%; - height: 100%; - display: block; +.status-badge-production { + color: #041c1c; + background: linear-gradient(120deg, #e6ffb2, #8ed26e); + border: none; } -.project-card-content { - padding: 25px; - display: flex; - flex-direction: column; - flex-grow: 1; +.status-badge-beta { + color: #1b1103; + background: linear-gradient(120deg, #ffd18d, #ffae5a); + border: none; } -.project-card-content h3 { - font-family: "Space Grotesk", sans-serif; - margin: 0; +.status-badge-wip { color: var(--text-gold); - font-size: 1.5em; + border-color: rgba(255, 211, 105, 0.4); } -.project-card-content p { - margin: 20px 0; +.card-body { + display: flex; + flex-direction: column; + gap: 12px; flex-grow: 1; - font-size: 1em; - color: var(--text-gold-strong); } -.learn-more { - background: transparent; - color: var(--accent-gold); - padding: 12px 24px; - border-radius: 4px; - font-weight: bold; - text-decoration: none; +.card-action { align-self: flex-start; - border: 1px solid var(--accent-outline); - transition: 0.3s; + border-radius: 999px; + padding: 0.55rem 1.6rem; + border: 1px solid rgba(255, 211, 105, 0.4); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-gold-strong); + transition: background 0.3s ease, color 0.3s ease; } -.learn-more:hover { +.card-action:hover, +.card-action:focus-visible { background: rgba(255, 211, 105, 0.2); + color: var(--accent-gold); } /* ---------- Footer ---------- */ @@ -476,12 +554,18 @@ mpr-footer { border-radius: 20px; } - h2 { - font-size: 1.8em; + .bands { + padding: 50px 16px 80px; + gap: 48px; } - .project-grid { - grid-template-columns: 1fr; + .band { + padding: 28px 22px 40px; + border-radius: 28px; + } + + .band-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .mpr-footer { @@ -529,20 +613,15 @@ mpr-footer { text-align: center; } - section:not(#top-content-section) { - padding: 30px 20px; + .band-heading h2 { + font-size: 2rem; } - h2 { - font-size: 1.6em; - margin-bottom: 30px; - } - - .project-icon-block { - height: 150px; + .band-grid { + grid-template-columns: 1fr; } - .project-card-content { + .project-card { padding: 20px; } diff --git a/tests/hero.spec.js b/tests/hero.spec.js index c863740..2761daf 100644 --- a/tests/hero.spec.js +++ b/tests/hero.spec.js @@ -2,6 +2,9 @@ const {test, expect} = require("@playwright/test"); +/** @type {{projects: Array<{name: string, status: string, category: string, description: string, url?: string|null}>}} */ +const catalog = require("../data/projects.json"); + test.describe("Marco Polo Research Lab landing page", () => { test("hero video renders with accessible controls", async ({page}) => { await page.goto("/index.html"); @@ -16,14 +19,39 @@ test.describe("Marco Polo Research Lab landing page", () => { await expect(toggle).toHaveAttribute("aria-pressed", "true"); }); - test("project gallery showcases flagship apps", async ({page}) => { + test("project bands render data-driven catalog", async ({page}) => { await page.goto("/index.html"); - await expect(page.getByRole("heading", {name: "Social Threader"})).toBeVisible(); - await expect(page.getByRole("heading", {name: "LLM Crossword"})).toBeVisible(); + for (const label of ["Research", "Tools", "Platform", "Products"]) { + const section = page.locator(`[data-band-category='${label}']`); + await expect(section.getByRole("heading", {name: label})).toBeVisible(); + await expect(section.locator(".project-card").first()).toBeVisible(); + } + await expect(page.getByRole("link", {name: "Discover Our Work"})).toBeVisible(); }); + test("project cards expose required metadata and links", async ({page}) => { + await page.goto("/index.html"); + + for (const project of catalog.projects) { + const card = page + .locator(".project-card") + .filter({has: page.getByRole("heading", {name: project.name})}); + + await expect(card).toContainText(project.description); + await expect(card.locator(".status-badge")).toHaveText(project.status); + + const action = card.locator("a.card-action"); + if (project.status === "WIP" || !project.url) { + await expect(action).toHaveCount(0); + } else { + await expect(action).toHaveCount(1); + await expect(action).toHaveAttribute("href", project.url); + } + } + }); + test("footer respects non-sticky configuration", async ({page}) => { await page.goto("/index.html"); From 7222076dd01eb73336364b434f4022a8cf4d6a47 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:27:34 -0800 Subject: [PATCH 02/31] chore: remove band borders for cleaner sections --- styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles.css b/styles.css index c37bbea..fa7444d 100644 --- a/styles.css +++ b/styles.css @@ -218,7 +218,7 @@ a:focus { position: relative; border-radius: 40px; padding: clamp(32px, 5vw, 72px); - border: 1px solid var(--accent-outline); + border: none; box-shadow: 0 45px 120px rgba(0, 0, 0, 0.55); overflow: hidden; } From 56cf20be4d78c8579fe3c39f9df16a118a0f668f Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:30:20 -0800 Subject: [PATCH 03/31] style: expand full-width color bands --- styles.css | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/styles.css b/styles.css index fa7444d..91ce638 100644 --- a/styles.css +++ b/styles.css @@ -207,19 +207,19 @@ a:focus { .bands { width: 100%; - box-sizing: border-box; - padding: 70px clamp(20px, 5vw, 80px) 110px; + margin: 60px 0 0; + padding: 0; display: flex; flex-direction: column; - gap: 70px; + gap: clamp(28px, 5vw, 60px); } .band { position: relative; - border-radius: 40px; - padding: clamp(32px, 5vw, 72px); + border-radius: 0; + padding: clamp(48px, 8vw, 96px) clamp(20px, 7vw, 120px); border: none; - box-shadow: 0 45px 120px rgba(0, 0, 0, 0.55); + box-shadow: none; overflow: hidden; } @@ -252,6 +252,9 @@ a:focus { position: relative; z-index: 1; margin-bottom: 32px; + max-width: 1100px; + margin-left: auto; + margin-right: auto; } .band-heading h2 { @@ -270,12 +273,15 @@ a:focus { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 28px; + max-width: 1100px; + margin-left: auto; + margin-right: auto; } .project-card { background: rgba(3, 27, 32, 0.85); border-radius: 24px; - border: 1px solid rgba(248, 227, 154, 0.25); + border: none; box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55); padding: 24px; display: flex; From eb181070dd31d0849b694a20ca2d3524194df9ec Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:31:31 -0800 Subject: [PATCH 04/31] style: convert sections into flush bands --- styles.css | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/styles.css b/styles.css index 91ce638..ab75b8d 100644 --- a/styles.css +++ b/styles.css @@ -209,29 +209,18 @@ a:focus { width: 100%; margin: 60px 0 0; padding: 0; - display: flex; - flex-direction: column; - gap: clamp(28px, 5vw, 60px); + display: block; } .band { position: relative; border-radius: 0; - padding: clamp(48px, 8vw, 96px) clamp(20px, 7vw, 120px); + padding: clamp(56px, 8vw, 110px) clamp(24px, 8vw, 140px); border: none; box-shadow: none; overflow: hidden; } -.band::before { - content: ""; - position: absolute; - inset: 0; - opacity: 0.4; - background: radial-gradient(circle at top, rgba(255, 211, 105, 0.2), transparent 60%); - pointer-events: none; -} - .band-research { background: linear-gradient(125deg, #03222b, #06424d); } @@ -281,7 +270,7 @@ a:focus { .project-card { background: rgba(3, 27, 32, 0.85); border-radius: 24px; - border: none; + border: 1px solid rgba(248, 227, 154, 0.25); box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55); padding: 24px; display: flex; From 3e53df64f5a69438419bbf82bddfa1f9b84815a5 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:34:41 -0800 Subject: [PATCH 05/31] style: ensure section bands span full viewport --- styles.css | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/styles.css b/styles.css index ab75b8d..bcff018 100644 --- a/styles.css +++ b/styles.css @@ -31,6 +31,10 @@ --mpr-chip-hover-bg: rgba(255, 211, 105, 0.28); --mpr-menu-hover-bg: rgba(255, 211, 105, 0.2); --mpr-theme-toggle-bg: rgba(0, 40, 46, 0.8); + --band-research: #052832; + --band-tools: #05333d; + --band-platform: #04222a; + --band-products: #031a21; } body { @@ -207,7 +211,7 @@ a:focus { .bands { width: 100%; - margin: 60px 0 0; + margin: 0; padding: 0; display: block; } @@ -215,26 +219,30 @@ a:focus { .band { position: relative; border-radius: 0; - padding: clamp(56px, 8vw, 110px) clamp(24px, 8vw, 140px); + width: 100vw; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); + padding: clamp(64px, 8vw, 120px) 0; + margin-bottom: clamp(32px, 5vw, 70px); border: none; box-shadow: none; overflow: hidden; } .band-research { - background: linear-gradient(125deg, #03222b, #06424d); + background: var(--band-research); } .band-tools { - background: linear-gradient(125deg, #052b36, #08464d); + background: var(--band-tools); } .band-platform { - background: linear-gradient(125deg, #041f27, #0b3e46); + background: var(--band-platform); } .band-products { - background: linear-gradient(125deg, #06212b, #0c3641); + background: var(--band-products); } .band-heading { @@ -244,6 +252,7 @@ a:focus { max-width: 1100px; margin-left: auto; margin-right: auto; + padding: 0 clamp(24px, 6vw, 120px); } .band-heading h2 { @@ -265,6 +274,7 @@ a:focus { max-width: 1100px; margin-left: auto; margin-right: auto; + padding: 0 clamp(24px, 6vw, 120px); } .project-card { From 5df5da5b7f8e8a72660b722d95bb41a3c4958fd0 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:37:26 -0800 Subject: [PATCH 06/31] style: remove spacing between full-width bands --- styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles.css b/styles.css index bcff018..e951d4d 100644 --- a/styles.css +++ b/styles.css @@ -223,7 +223,7 @@ a:focus { margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw); padding: clamp(64px, 8vw, 120px) 0; - margin-bottom: clamp(32px, 5vw, 70px); + margin-bottom: 0; border: none; box-shadow: none; overflow: hidden; From 0bf3bcf8a90bee6cda06386400a9253543207499 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 15:40:15 -0800 Subject: [PATCH 07/31] feat: introduce mpr-band component for full-width sections --- index.html | 49 +++++++++++++++++++++++++++++-------------------- mpr-band.js | 25 +++++++++++++++++++++++++ styles.css | 35 ++++++++++++++--------------------- 3 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 mpr-band.js diff --git a/index.html b/index.html index 6d7496c..58222a5 100644 --- a/index.html +++ b/index.html @@ -102,33 +102,41 @@

Marco Polo Research Lab

-
-
-

Research

+ +
+
+

Research

+
+
-
-
+ -
-
-

Tools

+ +
+
+

Tools

+
+
-
-
+ -
-
-

Platform

+ +
+
+

Platform

+
+
-
-
+ -
-
-

Products

+ +
+
+

Products

+
+
-
-
+
@@ -155,6 +163,7 @@

Products

> + diff --git a/mpr-band.js b/mpr-band.js new file mode 100644 index 0000000..91d3c22 --- /dev/null +++ b/mpr-band.js @@ -0,0 +1,25 @@ +// @ts-check + +class MprBand extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + shadowRoot.innerHTML = ` + + + `; + } +} + +customElements.define("mpr-band", MprBand); diff --git a/styles.css b/styles.css index e951d4d..7764883 100644 --- a/styles.css +++ b/styles.css @@ -219,16 +219,17 @@ a:focus { .band { position: relative; border-radius: 0; - width: 100vw; - margin-left: calc(50% - 50vw); - margin-right: calc(50% - 50vw); - padding: clamp(64px, 8vw, 120px) 0; - margin-bottom: 0; border: none; box-shadow: none; overflow: hidden; } +.band-inner { + max-width: 1100px; + margin: 0 auto; + padding: clamp(64px, 8vw, 120px) clamp(24px, 6vw, 120px); +} + .band-research { background: var(--band-research); } @@ -249,10 +250,7 @@ a:focus { position: relative; z-index: 1; margin-bottom: 32px; - max-width: 1100px; - margin-left: auto; - margin-right: auto; - padding: 0 clamp(24px, 6vw, 120px); + padding: 0; } .band-heading h2 { @@ -271,10 +269,7 @@ a:focus { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 28px; - max-width: 1100px; - margin-left: auto; - margin-right: auto; - padding: 0 clamp(24px, 6vw, 120px); + padding: 0; } .project-card { @@ -559,14 +554,8 @@ mpr-footer { border-radius: 20px; } - .bands { - padding: 50px 16px 80px; - gap: 48px; - } - - .band { - padding: 28px 22px 40px; - border-radius: 28px; + .band-inner { + padding: 40px 20px 60px; } .band-grid { @@ -618,6 +607,10 @@ mpr-footer { text-align: center; } + .band-inner { + padding: 32px 18px 48px; + } + .band-heading h2 { font-size: 2rem; } From 7fd52825d0bbc1b6a8e7a5d9bea4e83273eb2a3a Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:02:46 -0800 Subject: [PATCH 08/31] feature: add catalog-driven favicons to product bands (MP-103) --- assets/projects/ctx.png | Bin 0 -> 6518 bytes assets/projects/ghttp.png | Bin 0 -> 6518 bytes assets/projects/gix.png | Bin 0 -> 6518 bytes assets/projects/gravity-notes.png | Bin 0 -> 1803 bytes assets/projects/issues-md.png | Bin 0 -> 6518 bytes assets/projects/ledger.png | Bin 0 -> 6518 bytes assets/projects/loopaware.svg | 30 +++++++++++++++++++++ assets/projects/pinguin.png | Bin 0 -> 6518 bytes assets/projects/rsvp.png | Bin 0 -> 1477 bytes data/projects.json | 42 ++++++++++++++++++++---------- script.js | 12 ++++++++- styles.css | 8 ++++++ 12 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 assets/projects/ctx.png create mode 100644 assets/projects/ghttp.png create mode 100644 assets/projects/gix.png create mode 100644 assets/projects/gravity-notes.png create mode 100644 assets/projects/issues-md.png create mode 100644 assets/projects/ledger.png create mode 100644 assets/projects/loopaware.svg create mode 100644 assets/projects/pinguin.png create mode 100644 assets/projects/rsvp.png diff --git a/assets/projects/ctx.png b/assets/projects/ctx.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/ghttp.png b/assets/projects/ghttp.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/gix.png b/assets/projects/gix.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/gravity-notes.png b/assets/projects/gravity-notes.png new file mode 100644 index 0000000000000000000000000000000000000000..063f1d84bf292d2fd03860491b1335e7b8e37edf GIT binary patch literal 1803 zcmV+m2lV)fP)7RvxmGZfU?pOz^?H>*VrpIBCcj6t>=m95q|c` z;~YAg6pwxGsk>;7Jpoc9bPQ1`6!o?Bt;%w}(v!QU;qr=xZwh89?W_Cw`Ny9T?mfPj zq<#Vmj+jRfH#Bw)0LyT!3_!&IBbt`g1k@|y0@l6LqxLAOo~#=Xaa9XtOQTk$r?3Z1 zmIIE#Lv-NNvPy^L0hf4kc~tTB9b`*}svCMJD;Vz?8C(oyMTc@Cs755O@zD*h@x42a zgSnn)|5}Np)QF^&h(l4TZplm>)RJ~X0f#@w& zFhj&}Y6KI^9K@nNXXL~ zXwG_(#f0eBhmzhdZyhJ)NVQp)k$7Z&3c6JlmS|mm-Mb#LDK)|CT4X+|70}2Gg z%!7Hp{@&kn_&tASy15AE$)Y2oL{nNxhZr5(*Zha(*b;5?h#FSO(qY>iQXr*}TUn?u zE~Ye0_~{3J#XXzP($2=1>yG23XgCpN%%}W)ZY^UzW835rhk~fN)wQjdO#0^$@}s_iy~!{RGg!gZ|$zIM~^=!7O-r82Be6vaW8Fx6b-)`>Ui zT7nV*$W56_mY8W@$Hz{6iHC1_nuG5+$$U0}mx!5==ALs)Yrz}Ql>m&K>^%yW4;wD&+(`xk!Vntl$Wkf^SAa!sOLm{ z&$rR}qECUzXaGL+|SK&2)8NnQh z>+q9{yXnLUT(U?Qbi#7e>jO}!AR25-0c_XA>w`nECONNkrupUU?Ry_I`iEr zyyReznq!r_%Bi_69J{y|t#=CVb(#7>9t^9X90eetB(*jl9G@Yz1jUF5d31bb!*3bW zF7s)@dH?@aev$K?DGtuu$D-6wH`)Q0q3Bakq6Wbc39-fQ){DHm`4;WiK-I`ic~|oi zY4ps8CSc?yG&Ez}FK}w%Mh?#0$GL1BnzV_yVYDj)jtv|xQPAH2Q$Y+Kxc)hsnvqk( zV?{qPXlRC)00e?5|L&~i$Fm>e`?DXRV+opciP4vwsyJPlFFc}^E~pxbvB~?#Utv${ zWiDiEiX%bh|)DjCwjWE0hWM7&@NsA5giQ4RZFGcBl{j97IR_gT|kVEC3LI? zp*q2UfkxD&cv2$8q1xwrAZ<`-oul^vX3I|$o{{F5Kn#eW{TZ+~$C->?Iy_hUbN*1gilTCr^qK{l6GGlSSVNDS2(H%w3Nnn8M$B9UJ-~fP;01Rc@%w60Njz2cUhzL1R^!MONP1; z@=wi0mP4%kYKe|6D6I tx(Zf{)S8Cb^SpiO2tPP*oI|IR{{ioI+&t=haLoV!002ovPDHLkV1jquZ&m;R literal 0 HcmV?d00001 diff --git a/assets/projects/issues-md.png b/assets/projects/issues-md.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/ledger.png b/assets/projects/ledger.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/loopaware.svg b/assets/projects/loopaware.svg new file mode 100644 index 0000000..a48af6f --- /dev/null +++ b/assets/projects/loopaware.svg @@ -0,0 +1,30 @@ + + + + + + + diff --git a/assets/projects/pinguin.png b/assets/projects/pinguin.png new file mode 100644 index 0000000000000000000000000000000000000000..a59308e2e7ef39c9aa77eb03d70fa1c02c738429 GIT binary patch literal 6518 zcmeHKOK4nG7``(#F-UJFcjmrkGEJv3HpTdY_(DMjK}3)WLN-!CT_}P(LpOp<2i&Sv zV^wq`U5FcTrMntXi3QPiAx0%w3w5EXO`1MJ$By4ObN`uh&zw7xPU>z34(FWzeBbx~ z_dn0Etdy0u#>OnvC2QZ1W$m>rt5k~mu4V0qZ69oa0MWFgvMktp{TwHEFqg}HGden& z6>KJxaU93}9QGH*W~yD+eK(hLYbdK2&nuYF^MX1H^j}l#t)}B-7r{FVM-yZCwFa6E z8)FyR8t!N6IQ+iPvEgCoHpG1Z@h+lF3I^@FA=&UR8sNOaaoF$5*qJ(-o2gW4HJwh^ zQ7%H~e&LUdjFe&fOSn=a*4tgz`w*oLZVhR=V%yGx!qaQp&OU(e_*3}VZ1w}>bXodn zKcREJ_Yu>?x<{!O{dQpPz7^eGHL>9B5VB~fxk%C=#=vb! zAwLg6{|L%5Vqa%rn>LQMfP+RO13V{7C}k9$r;Q)Z(0+fOH?wnMV#4XgA0HntPEAdX z_QGv--1m3g90cK8ST}3FADl0j%Qtl6`hK`d9O{K)v3N^2Y_~Cd!;R3WpfvWhtlLtl zG}4U++*QGXyNOcm#nJVcLwv)JqNCO`Gc&_FUhM<>uZm|IS|&!I&8rb1Xc)o}8S_1VQjH=IIsie{J(S z=K^QS+vHDAPusw)Ct$C_Z*_Kdb_*}ecr?rrxbx6W+7Ix_9Os`*@Qr;N&c{`+y@%XA zldMnQ`NQV%@OZsmZ@ew8-D{kI@A$&(BYzlU-!F#(P%rLH`%9PtL2hD6W<5dyFe!9UkxW zL*HTRe}ar7u8r%p_9O~u8s?e4&1V+RUg2)H#{7@Tx5ex57~)!p+nQ$;><72&SNQnG zXA^uZblDpl`Fwu2X!Kj-9?RidVt1ItLf?GPpx^l4CU&VY{&c^7g~vC9=xOzuyrmG| z5pAq-XX5*3MW^4I?=F)zeTruE#@Ntti2WYHu;zUM+pVVim-_{|-mAaSagOcLb>SPn zN};L^TUE#Ly+aJ&+|YRrXGP;bKF7;rFL#R?>G8VeX||C zx0m8Egg-&+6X5@j+dJ=b!}veoev0zFItHyJ)BB4tFekvDR`J)jP3?Hz=~w;ve({d~ zCx-AewN+n9?Pxt}bFrnIcf2*3KjysGck8i4>!Av6$G+I=oREuBO`31rr>P(1g6D(r zj7o?F|5bUWbf0UJza$&?6VFTN;=bUW6)s2nFuFsVcZr;-Njd}jAOnL83^E`Y_zzV~ B;@1EG literal 0 HcmV?d00001 diff --git a/assets/projects/rsvp.png b/assets/projects/rsvp.png new file mode 100644 index 0000000000000000000000000000000000000000..98dd839f812033e1eeccabaf9a5b455129676488 GIT binary patch literal 1477 zcmV;$1v>hPP)OAlQNs zD;8{!Sg;AuC~*L_3si}h3IY-pyRkzOBO1r{_2YWx&YbhJm^+Vqok-zm7Bfd@zVCm2 z{~=y_2mkLP06-t>tc9?Dl=+@k-U}dNcpcq&I00EXN-=_mHFoN|hTnz*vZ_eZU;CTooh$ zfyp1~KVl>dXuP~|nyYW8m)~^Oh_$qq*3cSe??&VoZ|KUm-HV^4aSQ{BVF3bTvX`SC zsCPDY1_@429Zrt{o&_EI)B<8`$;k%w4Al|r(2!xJ@wbEBoumr4stArzJ8-=g0Y|# z8^gEeZ$3A9@cQ)ng}*Fq_8O$uiU$ROs<#v2ehtY=w>1^|!_DpQUYq^rfBo~#2ODv- zlUN~0IA!GI+0pyo|8QY(z0ujeH$JB;2ctD{6ZLd6wKS9lF+zY@Q!y)b54LO?h~G^Drxd6H%bk;ZE-af5urQS>N;y6ne&b8C z%sa~|2^DCLUhQY3Dju{{fdZ<)AFkK-b_7<7uCb6j2ZRboLx1tqZVr;;G6&VXPDyyc z0)aE?W5rNl2G^$cWK%ttdH0%Cv|Ec}xqw#lN*f}wh zdQXyG46K1+#e*Wr1XLi#2nkODQ0JAT5P*_YcmPhNS$7|A*IrwmUBBCi#Qf~yMto?5 zfEdUSfe4t3s0xe}a;2(a=;7H*#0XWDMCurZZe^!-@yZjocWP5J$v4mJ_(U=zAcPT^ zQt}GXFold{6`+bB@IPOf_~WO|THU1{BvQ{X^sAlP($!P9JN0(U|LW4c7w5WZnt2IG zCTpeuEH$pbMUrrepyn>GjlaG;|H^Mq{(Z9^hCX&C4E-m&;nMQ_dZ*ED`74+1oSQj_ zqd+9rn?4i^z>1ohtVbjZh4(Vw>dj2`?sw~7{>_vB*lILey_HV5bp7;Nr`c|~D;IB{ zojHgiYfV(4Mh80MFBXBW(Go(PX$0#g&N0Zaq95e%eSV-d;i`adF_LRe|1LMqv?AW zZk;``*GDgw<{p&uz!W(sa{^NHKDUA>wu_V9YhPTQX(e#AcEi8-((1YC{kT^PL_y{~ zn0Jb>Up4F_=eaE>n$dnYL4iPg6r07#?$z@vU;WI^ug|ZXJFyo<*3v6fRiWCK1&XQ` zxJAG#T7;|*7|{1TbMX-php}B8KfHY5V>osrYeZ8m$5g-ERcrLQm8DnZ$W(yByHWGT zUj5ARJ){-l;)Frd9Jw`-&=khqcrbS7u)%;@GO&~i1q9l4_SHY1`PRa{$-oue$ichF z!!CBZo!TFKGS}6b81DxTf9LuD3;`!_^2m!@zbKiTU-i$!_@Rqo=E1-}VpJ+abvR0W z7fQwNk1E0|yqeNbWH~UyvG#y})ZMfSnipFc!GXkQg9Md%@R(~j!;k(1kcVi Date: Fri, 21 Nov 2025 19:06:17 -0800 Subject: [PATCH 09/31] chore: provision icon slots for remaining projects --- data/projects.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/data/projects.json b/data/projects.json index 0a48bb8..3b5f605 100644 --- a/data/projects.json +++ b/data/projects.json @@ -18,7 +18,7 @@ "category": "Research", "url": null, "public": false, - "icon": null + "icon": "assets/projects/photolab.png" }, { "id": "ctx", @@ -78,7 +78,7 @@ "category": "Platform", "url": "https://ets.mprlab.com", "public": true, - "icon": null + "icon": "assets/projects/ets.png" }, { "id": "tauth", @@ -88,7 +88,7 @@ "category": "Platform", "url": "https://tauth.mprlab.com", "public": true, - "icon": null + "icon": "assets/projects/tauth.png" }, { "id": "ledger", @@ -108,7 +108,7 @@ "category": "Products", "url": "https://ps.mprlab.com", "public": true, - "icon": null + "icon": "assets/projects/product-scanner.png" }, { "id": "sheet2tube", @@ -118,7 +118,7 @@ "category": "Products", "url": "https://sheet2tube.mprlab.com", "public": true, - "icon": null + "icon": "assets/projects/sheet2tube.png" }, { "id": "gravity-notes", From 89773db9c1d183ee187289a15a337b69fc01ea86 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:09:29 -0800 Subject: [PATCH 10/31] feat: add handcrafted SVG favicons for remaining catalog projects --- assets/projects/ets.svg | 34 +++++++++++++++++++++++ assets/projects/photolab.svg | 27 ++++++++++++++++++ assets/projects/product-scanner.svg | 43 +++++++++++++++++++++++++++++ assets/projects/sheet2tube.svg | 34 +++++++++++++++++++++++ assets/projects/tauth.svg | 23 +++++++++++++++ data/projects.json | 10 +++---- 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 assets/projects/ets.svg create mode 100644 assets/projects/photolab.svg create mode 100644 assets/projects/product-scanner.svg create mode 100644 assets/projects/sheet2tube.svg create mode 100644 assets/projects/tauth.svg diff --git a/assets/projects/ets.svg b/assets/projects/ets.svg new file mode 100644 index 0000000..3177af0 --- /dev/null +++ b/assets/projects/ets.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/assets/projects/photolab.svg b/assets/projects/photolab.svg new file mode 100644 index 0000000..78b159a --- /dev/null +++ b/assets/projects/photolab.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/assets/projects/product-scanner.svg b/assets/projects/product-scanner.svg new file mode 100644 index 0000000..4a02f89 --- /dev/null +++ b/assets/projects/product-scanner.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/assets/projects/sheet2tube.svg b/assets/projects/sheet2tube.svg new file mode 100644 index 0000000..97eee48 --- /dev/null +++ b/assets/projects/sheet2tube.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/assets/projects/tauth.svg b/assets/projects/tauth.svg new file mode 100644 index 0000000..ff89303 --- /dev/null +++ b/assets/projects/tauth.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/data/projects.json b/data/projects.json index 3b5f605..3aa8413 100644 --- a/data/projects.json +++ b/data/projects.json @@ -18,7 +18,7 @@ "category": "Research", "url": null, "public": false, - "icon": "assets/projects/photolab.png" + "icon": "assets/projects/photolab.svg" }, { "id": "ctx", @@ -78,7 +78,7 @@ "category": "Platform", "url": "https://ets.mprlab.com", "public": true, - "icon": "assets/projects/ets.png" + "icon": "assets/projects/ets.svg" }, { "id": "tauth", @@ -88,7 +88,7 @@ "category": "Platform", "url": "https://tauth.mprlab.com", "public": true, - "icon": "assets/projects/tauth.png" + "icon": "assets/projects/tauth.svg" }, { "id": "ledger", @@ -108,7 +108,7 @@ "category": "Products", "url": "https://ps.mprlab.com", "public": true, - "icon": "assets/projects/product-scanner.png" + "icon": "assets/projects/product-scanner.svg" }, { "id": "sheet2tube", @@ -118,7 +118,7 @@ "category": "Products", "url": "https://sheet2tube.mprlab.com", "public": true, - "icon": "assets/projects/sheet2tube.png" + "icon": "assets/projects/sheet2tube.svg" }, { "id": "gravity-notes", From 1b8c94c2f7f2086d73e13b2bda9d134e33836016 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:17:41 -0800 Subject: [PATCH 11/31] layout: move Research band below Products --- index.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 58222a5..d356772 100644 --- a/index.html +++ b/index.html @@ -102,37 +102,37 @@

Marco Polo Research Lab

- +
-

Research

+

Tools

- +
-

Tools

+

Platform

- +
-

Platform

+

Products

- +
-

Products

+

Research

From 53b9dd71aed47dce9fe87182294b0e7347a85022 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:22:41 -0800 Subject: [PATCH 12/31] layout: make band grids left-aligned two-up cards --- styles.css | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/styles.css b/styles.css index 6a2a0aa..7f2ca37 100644 --- a/styles.css +++ b/styles.css @@ -266,8 +266,8 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 28px; padding: 0; } @@ -285,6 +285,7 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; + flex: 0 0 calc(50% - 14px); } .project-card:hover { @@ -566,10 +567,6 @@ mpr-footer { padding: 40px 20px 60px; } - .band-grid { - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - } - .mpr-footer { padding: 40px 0 32px; } @@ -623,12 +620,9 @@ mpr-footer { font-size: 2rem; } - .band-grid { - grid-template-columns: 1fr; - } - .project-card { padding: 20px; + flex: 0 0 100%; } .mpr-footer { From 1fc58ede62cb3358e9daa720cc8eaf57ae667eb6 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:25:07 -0800 Subject: [PATCH 13/31] layout: enforce two-column grid for bands --- styles.css | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/styles.css b/styles.css index 7f2ca37..be2a2be 100644 --- a/styles.css +++ b/styles.css @@ -266,8 +266,8 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; padding: 0; } @@ -285,7 +285,6 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; - flex: 0 0 calc(50% - 14px); } .project-card:hover { @@ -563,10 +562,6 @@ mpr-footer { border-radius: 20px; } - .band-inner { - padding: 40px 20px 60px; - } - .mpr-footer { padding: 40px 0 32px; } @@ -622,7 +617,6 @@ mpr-footer { .project-card { padding: 20px; - flex: 0 0 100%; } .mpr-footer { From d330c04f36da0df216fc4295671e32bdf4e56f0b Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:32:52 -0800 Subject: [PATCH 14/31] layout: widen bands and allow up to three cards per row --- styles.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/styles.css b/styles.css index be2a2be..118e836 100644 --- a/styles.css +++ b/styles.css @@ -225,9 +225,9 @@ a:focus { } .band-inner { - max-width: 1100px; + width: 100%; margin: 0 auto; - padding: clamp(64px, 8vw, 120px) clamp(24px, 6vw, 120px); + padding: clamp(56px, 7vw, 96px) clamp(10px, 4vw, 40px); } .band-research { @@ -267,8 +267,8 @@ a:focus { position: relative; z-index: 1; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 28px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 24px; padding: 0; } From 9f7d522441bf2f71fda703034efdb6ac0b0a2fde Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:35:39 -0800 Subject: [PATCH 15/31] layout: fix card width and wrap rows --- styles.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index 118e836..8f778c3 100644 --- a/styles.css +++ b/styles.css @@ -266,8 +266,8 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 24px; padding: 0; } @@ -285,6 +285,9 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; + width: 320px; + max-width: 100%; + flex: 0 0 320px; } .project-card:hover { @@ -617,6 +620,8 @@ mpr-footer { .project-card { padding: 20px; + width: 100%; + flex: 1 0 100%; } .mpr-footer { From 4c9bf6785b295bb54ee13633e238839c24c7b8d8 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:38:08 -0800 Subject: [PATCH 16/31] layout: widen fixed card width to match original feel --- styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index 8f778c3..3f1a4a5 100644 --- a/styles.css +++ b/styles.css @@ -285,9 +285,9 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; - width: 320px; + width: 360px; max-width: 100%; - flex: 0 0 320px; + flex: 0 0 360px; } .project-card:hover { From 1189768d480da3774b0cb5f485e7ff71d038939c Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:44:29 -0800 Subject: [PATCH 17/31] layout: restore centered 2-up grid with original card length --- styles.css | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/styles.css b/styles.css index 3f1a4a5..16c3467 100644 --- a/styles.css +++ b/styles.css @@ -225,9 +225,9 @@ a:focus { } .band-inner { - width: 100%; + max-width: 1100px; margin: 0 auto; - padding: clamp(56px, 7vw, 96px) clamp(10px, 4vw, 40px); + padding: clamp(64px, 8vw, 110px) clamp(24px, 6vw, 64px); } .band-research { @@ -266,9 +266,9 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: flex; - flex-wrap: wrap; - gap: 24px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px; padding: 0; } @@ -285,9 +285,6 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; - width: 360px; - max-width: 100%; - flex: 0 0 360px; } .project-card:hover { @@ -618,10 +615,12 @@ mpr-footer { font-size: 2rem; } + .band-grid { + grid-template-columns: 1fr; + } + .project-card { padding: 20px; - width: 100%; - flex: 1 0 100%; } .mpr-footer { From a860b2bbe90497d3973e42ad913f630b1f4263a2 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:50:20 -0800 Subject: [PATCH 18/31] layout: center-wrapped fixed-width cards in bands --- styles.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/styles.css b/styles.css index 16c3467..dbe8d8f 100644 --- a/styles.css +++ b/styles.css @@ -225,7 +225,7 @@ a:focus { } .band-inner { - max-width: 1100px; + width: 100%; margin: 0 auto; padding: clamp(64px, 8vw, 110px) clamp(24px, 6vw, 64px); } @@ -266,8 +266,9 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-wrap: wrap; + justify-content: center; gap: 28px; padding: 0; } @@ -285,6 +286,9 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; + width: 360px; + max-width: 100%; + flex: 0 0 360px; } .project-card:hover { @@ -616,11 +620,13 @@ mpr-footer { } .band-grid { - grid-template-columns: 1fr; + justify-content: center; } .project-card { padding: 20px; + width: 100%; + flex: 1 0 100%; } .mpr-footer { From d2358e2eed5d3f82b1fc281cc19d2bc567b3fbd7 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 19:53:19 -0800 Subject: [PATCH 19/31] layout: lock bands to centered 2-column grid with dynamic card width --- styles.css | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/styles.css b/styles.css index dbe8d8f..16c3467 100644 --- a/styles.css +++ b/styles.css @@ -225,7 +225,7 @@ a:focus { } .band-inner { - width: 100%; + max-width: 1100px; margin: 0 auto; padding: clamp(64px, 8vw, 110px) clamp(24px, 6vw, 64px); } @@ -266,9 +266,8 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: flex; - flex-wrap: wrap; - justify-content: center; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; padding: 0; } @@ -286,9 +285,6 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; - width: 360px; - max-width: 100%; - flex: 0 0 360px; } .project-card:hover { @@ -620,13 +616,11 @@ mpr-footer { } .band-grid { - justify-content: center; + grid-template-columns: 1fr; } .project-card { padding: 20px; - width: 100%; - flex: 1 0 100%; } .mpr-footer { From dd443742f160d84c9282f350d33b523507b9718a Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 20:00:20 -0800 Subject: [PATCH 20/31] layout: full-width bands with centered fixed-width cards and wrapping --- styles.css | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/styles.css b/styles.css index 16c3467..6458c65 100644 --- a/styles.css +++ b/styles.css @@ -225,9 +225,9 @@ a:focus { } .band-inner { - max-width: 1100px; - margin: 0 auto; - padding: clamp(64px, 8vw, 110px) clamp(24px, 6vw, 64px); + width: 100%; + margin: 0; + padding: clamp(64px, 8vw, 110px) 0; } .band-research { @@ -266,8 +266,9 @@ a:focus { .band-grid { position: relative; z-index: 1; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-wrap: wrap; + justify-content: center; gap: 28px; padding: 0; } @@ -285,6 +286,9 @@ a:focus { overflow: hidden; min-height: 260px; transition: transform 0.3s ease, border-color 0.3s ease; + width: 520px; + max-width: 100%; + flex: 0 0 520px; } .project-card:hover { @@ -616,11 +620,13 @@ mpr-footer { } .band-grid { - grid-template-columns: 1fr; + justify-content: center; } .project-card { padding: 20px; + width: 100%; + flex: 1 0 100%; } .mpr-footer { From 620a0fe331fcd50c5605db9480f3b1e931ad3f29 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 20:13:05 -0800 Subject: [PATCH 21/31] layout: center full rows and left-align final partial row via band rows --- script.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 20 +++++++++++++++--- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/script.js b/script.js index 2364fda..c34850c 100644 --- a/script.js +++ b/script.js @@ -144,6 +144,8 @@ function renderProjectBands(projects) { scopedProjects.forEach(project => { grid.append(buildProjectCard(project)); }); + + layoutBandRows(/** @type {HTMLElement} */ (grid)); }); } @@ -171,6 +173,58 @@ async function hydrateProjectCatalog() { } } +const CARD_WIDTH_PX = 520; +const CARD_GAP_PX = 28; +const MOBILE_BREAKPOINT = 600; + +/** + * Arrange project cards into centered full rows and a left-aligned last row. + * @param {HTMLElement} grid + */ +function layoutBandRows(grid) { + const allCards = /** @type {HTMLElement[]} */ (Array.from( + grid.querySelectorAll(".project-card"), + )); + if (!allCards.length) return; + + if (window.innerWidth <= MOBILE_BREAKPOINT) { + grid.innerHTML = ""; + allCards.forEach(card => grid.append(card)); + return; + } + + grid.innerHTML = ""; + + const containerWidth = grid.getBoundingClientRect().width || window.innerWidth; + const step = CARD_WIDTH_PX + CARD_GAP_PX; + const maxPerRow = Math.max(1, Math.floor((containerWidth + CARD_GAP_PX) / step)); + const total = allCards.length; + + if (total <= maxPerRow) { + const singleRow = document.createElement("div"); + singleRow.className = "band-row band-row-center"; + allCards.forEach(card => singleRow.append(card)); + grid.append(singleRow); + return; + } + + let index = 0; + while (index < total) { + const rowCards = allCards.slice(index, index + maxPerRow); + const row = document.createElement("div"); + row.className = "band-row"; + const isLastRow = index + maxPerRow >= total; + if (!isLastRow || rowCards.length === maxPerRow) { + row.classList.add("band-row-center"); + } else { + row.classList.add("band-row-left"); + } + rowCards.forEach(card => row.append(card)); + grid.append(row); + index += maxPerRow; + } +} + function setupHeroAudioToggle() { const video = document.getElementById("hero-video"); const toggle = document.getElementById("hero-sound-toggle"); @@ -207,4 +261,9 @@ document.addEventListener("DOMContentLoaded", () => { hydrateProjectCatalog().catch(error => { console.error("Initialization error:", error); }); + + window.addEventListener("resize", () => { + const grids = document.querySelectorAll("[data-band-cards]"); + grids.forEach(grid => layoutBandRows(/** @type {HTMLElement} */ (grid))); + }); }); diff --git a/styles.css b/styles.css index 6458c65..3efc5b3 100644 --- a/styles.css +++ b/styles.css @@ -267,12 +267,25 @@ a:focus { position: relative; z-index: 1; display: flex; - flex-wrap: wrap; - justify-content: center; + flex-direction: column; gap: 28px; padding: 0; } +.band-row { + display: flex; + gap: 28px; + width: 100%; +} + +.band-row-center { + justify-content: center; +} + +.band-row-left { + justify-content: flex-start; +} + .project-card { background: rgba(3, 27, 32, 0.85); border-radius: 24px; @@ -619,7 +632,8 @@ mpr-footer { font-size: 2rem; } - .band-grid { + .band-row { + flex-wrap: wrap; justify-content: center; } From 4af5498d5fb5c09e5fd7ddb8d2cd0520709c7a1a Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 22:35:22 -0800 Subject: [PATCH 22/31] style: add horizontal inset for band titles --- styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/styles.css b/styles.css index 3efc5b3..cd2ff81 100644 --- a/styles.css +++ b/styles.css @@ -255,6 +255,7 @@ a:focus { .band-heading h2 { margin: 0; + margin-left: clamp(24px, 4vw, 64px); font-size: clamp(2rem, 4vw, 3rem); font-family: "Orbitron", "Space Grotesk", sans-serif; text-transform: uppercase; From 660e7ca3643f94688c3176cd3ea0723ab5f32cb0 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 23:03:09 -0800 Subject: [PATCH 23/31] style: tighten band top padding and increase spacing beneath headings --- styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index cd2ff81..db5f6cb 100644 --- a/styles.css +++ b/styles.css @@ -227,7 +227,7 @@ a:focus { .band-inner { width: 100%; margin: 0; - padding: clamp(64px, 8vw, 110px) 0; + padding: clamp(40px, 6vw, 80px) 0; } .band-research { @@ -249,7 +249,7 @@ a:focus { .band-heading { position: relative; z-index: 1; - margin-bottom: 32px; + margin-bottom: 44px; padding: 0; } From 43d425386db149e5438b01be359f91c03d07c70a Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 23:27:15 -0800 Subject: [PATCH 24/31] layout: alternate left/right band rows with fixed-width cards --- script.js | 22 ++++++++++++++++------ styles.css | 8 ++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/script.js b/script.js index c34850c..48f25cd 100644 --- a/script.js +++ b/script.js @@ -178,7 +178,9 @@ const CARD_GAP_PX = 28; const MOBILE_BREAKPOINT = 600; /** - * Arrange project cards into centered full rows and a left-aligned last row. + * Arrange project cards into rows with fixed-width cards: + * - full rows alternate between left and right alignment + * - the final partial row (if any) is left-aligned so it tucks under the first column. * @param {HTMLElement} grid */ function layoutBandRows(grid) { @@ -202,26 +204,34 @@ function layoutBandRows(grid) { if (total <= maxPerRow) { const singleRow = document.createElement("div"); - singleRow.className = "band-row band-row-center"; + singleRow.className = "band-row band-row-left"; allCards.forEach(card => singleRow.append(card)); grid.append(singleRow); return; } let index = 0; + let rowIndex = 0; while (index < total) { const rowCards = allCards.slice(index, index + maxPerRow); const row = document.createElement("div"); row.className = "band-row"; + const isLastRow = index + maxPerRow >= total; - if (!isLastRow || rowCards.length === maxPerRow) { - row.classList.add("band-row-center"); - } else { - row.classList.add("band-row-left"); + const isFullRow = rowCards.length === maxPerRow; + + let alignLeft = rowIndex % 2 === 0; + if (isLastRow && !isFullRow) { + alignLeft = true; } + + row.classList.add(alignLeft ? "band-row-left" : "band-row-right"); + rowCards.forEach(card => row.append(card)); grid.append(row); + index += maxPerRow; + rowIndex += 1; } } diff --git a/styles.css b/styles.css index db5f6cb..29e5e90 100644 --- a/styles.css +++ b/styles.css @@ -279,14 +279,14 @@ a:focus { width: 100%; } -.band-row-center { - justify-content: center; -} - .band-row-left { justify-content: flex-start; } +.band-row-right { + justify-content: flex-end; +} + .project-card { background: rgba(3, 27, 32, 0.85); border-radius: 24px; From ddb6ee632fe13bb297dbcbdbcfd252a2c89093a7 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 21 Nov 2025 23:43:20 -0800 Subject: [PATCH 25/31] layout: add 10px band-row inset and correct maxPerRow to prevent overflow --- script.js | 4 +++- styles.css | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/script.js b/script.js index 48f25cd..229296f 100644 --- a/script.js +++ b/script.js @@ -176,6 +176,7 @@ async function hydrateProjectCatalog() { const CARD_WIDTH_PX = 520; const CARD_GAP_PX = 28; const MOBILE_BREAKPOINT = 600; +const BAND_ROW_PADDING_PX = 10; /** * Arrange project cards into rows with fixed-width cards: @@ -199,7 +200,8 @@ function layoutBandRows(grid) { const containerWidth = grid.getBoundingClientRect().width || window.innerWidth; const step = CARD_WIDTH_PX + CARD_GAP_PX; - const maxPerRow = Math.max(1, Math.floor((containerWidth + CARD_GAP_PX) / step)); + const usableWidth = Math.max(0, containerWidth - BAND_ROW_PADDING_PX * 2); + const maxPerRow = Math.max(1, Math.floor((usableWidth + CARD_GAP_PX) / step)); const total = allCards.length; if (total <= maxPerRow) { diff --git a/styles.css b/styles.css index 29e5e90..f621cbd 100644 --- a/styles.css +++ b/styles.css @@ -277,6 +277,8 @@ a:focus { display: flex; gap: 28px; width: 100%; + padding: 0 10px; + box-sizing: border-box; } .band-row-left { From d512b23c6c10709f36558c97f233dd63f1a43f18 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:08:23 -0800 Subject: [PATCH 26/31] style: remove rounded corners from hero media container --- styles.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/styles.css b/styles.css index f621cbd..cf6d463 100644 --- a/styles.css +++ b/styles.css @@ -70,7 +70,7 @@ body { position: relative; width: min(1100px, 92vw); aspect-ratio: 1020 / 1088; - border-radius: 28px; + border-radius: 0; overflow: hidden; box-shadow: 0 40px 120px var(--shadow-strong); } @@ -579,7 +579,7 @@ mpr-footer { .hero-media { width: 100%; - border-radius: 20px; + border-radius: 0; } .mpr-footer { @@ -619,7 +619,7 @@ mpr-footer { } .hero-media { - border-radius: 16px; + border-radius: 0; } .hero-cta-button { From 60e0460c45f2d45d2567345fc760f53357c04f98 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:11:29 -0800 Subject: [PATCH 27/31] feat: wrap hero section in band container --- index.html | 74 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index d356772..e1ba939 100644 --- a/index.html +++ b/index.html @@ -57,48 +57,50 @@ -
-
- - -
+ Toggle sound + + + +
-
-

Marco Polo Research Lab

+
+

Marco Polo Research Lab

-

The new platform you can stand on.

+

The new platform you can stand on.

- -
- + +
From 26ba0dd2b5aabbbfac69e00edc73beb7f03689dd Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:17:28 -0800 Subject: [PATCH 28/31] style: force rectangular hero band (section, media, overlay, video) --- styles.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/styles.css b/styles.css index cf6d463..c0f8f35 100644 --- a/styles.css +++ b/styles.css @@ -64,6 +64,7 @@ body { flex-direction: column; align-items: center; gap: 32px; + border-radius: 0; } .hero-media { @@ -80,6 +81,7 @@ body { position: absolute; inset: 0; background: linear-gradient(120deg, rgba(0, 44, 48, 0.65) 0%, rgba(0, 84, 93, 0.4) 55%, rgba(0, 32, 34, 0.7) 100%); + border-radius: 0; pointer-events: none; z-index: 1; } @@ -91,6 +93,7 @@ body { height: 100%; object-fit: cover; filter: saturate(1.2) contrast(1.05); + border-radius: 0; z-index: 0; } From 99f169ce04878660ce0de7d15f6579fa5b33eacd Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:22:28 -0800 Subject: [PATCH 29/31] layout: clamp cards to at most two per row and alternate full/partial rows left-right --- script.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/script.js b/script.js index 229296f..4510400 100644 --- a/script.js +++ b/script.js @@ -181,7 +181,7 @@ const BAND_ROW_PADDING_PX = 10; /** * Arrange project cards into rows with fixed-width cards: * - full rows alternate between left and right alignment - * - the final partial row (if any) is left-aligned so it tucks under the first column. + * - the final row (even if partial) follows the same alternation pattern * @param {HTMLElement} grid */ function layoutBandRows(grid) { @@ -201,7 +201,8 @@ function layoutBandRows(grid) { const containerWidth = grid.getBoundingClientRect().width || window.innerWidth; const step = CARD_WIDTH_PX + CARD_GAP_PX; const usableWidth = Math.max(0, containerWidth - BAND_ROW_PADDING_PX * 2); - const maxPerRow = Math.max(1, Math.floor((usableWidth + CARD_GAP_PX) / step)); + const computedPerRow = Math.floor((usableWidth + CARD_GAP_PX) / step); + const maxPerRow = Math.max(1, Math.min(2, computedPerRow)); const total = allCards.length; if (total <= maxPerRow) { @@ -219,13 +220,7 @@ function layoutBandRows(grid) { const row = document.createElement("div"); row.className = "band-row"; - const isLastRow = index + maxPerRow >= total; - const isFullRow = rowCards.length === maxPerRow; - - let alignLeft = rowIndex % 2 === 0; - if (isLastRow && !isFullRow) { - alignLeft = true; - } + const alignLeft = rowIndex % 2 === 0; row.classList.add(alignLeft ? "band-row-left" : "band-row-right"); From ab7978f725ad61f26f57455a5bda7b7ea88c6460 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:32:09 -0800 Subject: [PATCH 30/31] layout: increase band-row horizontal inset to give right-aligned cards breathing room --- script.js | 2 +- styles.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script.js b/script.js index 4510400..b7d29e3 100644 --- a/script.js +++ b/script.js @@ -176,7 +176,7 @@ async function hydrateProjectCatalog() { const CARD_WIDTH_PX = 520; const CARD_GAP_PX = 28; const MOBILE_BREAKPOINT = 600; -const BAND_ROW_PADDING_PX = 10; +const BAND_ROW_PADDING_PX = 24; /** * Arrange project cards into rows with fixed-width cards: diff --git a/styles.css b/styles.css index c0f8f35..ee6c3dc 100644 --- a/styles.css +++ b/styles.css @@ -280,7 +280,7 @@ a:focus { display: flex; gap: 28px; width: 100%; - padding: 0 10px; + padding: 0 24px; box-sizing: border-box; } From 31f6997f2af41b2479f97f3fbafe651663d0e40e Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 22 Nov 2025 00:34:13 -0800 Subject: [PATCH 31/31] Docker image path for ghttp fixed --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 08235a6..27b8f59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: site: - image: ghcr.io/temirov/ghttp:latest + image: ghcr.io/tyemirov/ghttp:latest container_name: mprlab-site restart: unless-stopped env_file: