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/assets/projects/ctx.png b/assets/projects/ctx.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/ctx.png differ 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/ghttp.png b/assets/projects/ghttp.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/ghttp.png differ diff --git a/assets/projects/gix.png b/assets/projects/gix.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/gix.png differ diff --git a/assets/projects/gravity-notes.png b/assets/projects/gravity-notes.png new file mode 100644 index 0000000..063f1d8 Binary files /dev/null and b/assets/projects/gravity-notes.png differ diff --git a/assets/projects/issues-md.png b/assets/projects/issues-md.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/issues-md.png differ diff --git a/assets/projects/ledger.png b/assets/projects/ledger.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/ledger.png differ 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/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/pinguin.png b/assets/projects/pinguin.png new file mode 100644 index 0000000..a59308e Binary files /dev/null and b/assets/projects/pinguin.png differ 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/rsvp.png b/assets/projects/rsvp.png new file mode 100644 index 0000000..98dd839 Binary files /dev/null and b/assets/projects/rsvp.png differ 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 new file mode 100644 index 0000000..3aa8413 --- /dev/null +++ b/data/projects.json @@ -0,0 +1,144 @@ +{ + "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, + "icon": "assets/projects/issues-md.png" + }, + { + "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, + "icon": "assets/projects/photolab.svg" + }, + { + "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, + "icon": "assets/projects/ctx.png" + }, + { + "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, + "icon": "assets/projects/gix.png" + }, + { + "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, + "icon": "assets/projects/ghttp.png" + }, + { + "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, + "icon": "assets/projects/loopaware.svg" + }, + { + "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, + "icon": "assets/projects/pinguin.png" + }, + { + "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, + "icon": "assets/projects/ets.svg" + }, + { + "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, + "icon": "assets/projects/tauth.svg" + }, + { + "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, + "icon": "assets/projects/ledger.png" + }, + { + "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, + "icon": "assets/projects/product-scanner.svg" + }, + { + "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, + "icon": "assets/projects/sheet2tube.svg" + }, + { + "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, + "icon": "assets/projects/gravity-notes.png" + }, + { + "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, + "icon": "assets/projects/rsvp.png" + } + ] +} 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: diff --git a/index.html b/index.html index b96340c..e1ba939 100644 --- a/index.html +++ b/index.html @@ -57,204 +57,89 @@ -
-
- - -
+ Toggle sound + + + + -
-

Marco Polo Research Lab

+
+

Marco Polo Research Lab

-

The new platform you can stand on.

+

The new platform you can stand on.

- -
-
- - -
- -
-

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. -

+ - -
-

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 -
+ +
+ +
+
+

Tools

-
-
- -
-
-

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 -
+
+
+ + + +
+
+

Platform

-
-
- -
-
-

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 -
+
+
+ + + +
+
+

Products

+
-
+ - -
-

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

-
-
+ +
+
+

Research

+
+
+
+
+ Chart Your Course With Us > - + 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/script.js b/script.js index 6ce7aad..b7d29e3 100644 --- a/script.js +++ b/script.js @@ -1,365 +1,235 @@ -/* ---------- 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 - }); - - 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; - - 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; - } - } - 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; +// @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] + * @property {string | null | undefined} [icon] + */ + +const SECTION_ORDER = /** @type {ProjectCategory[]} */ ([ + "Research", + "Tools", + "Platform", + "Products" +]); + +const STATUS_PRIORITY = Object.freeze({ + Production: 0, + Beta: 1, + WIP: 2 +}); - const vb = (svg.getAttribute("viewBox") || "0 0 300 300") - .split(/[ ,]+/).map(Number); +const STATUS_CLASS = Object.freeze({ + Production: "status-badge-production", + Beta: "status-badge-beta", + WIP: "status-badge-wip" +}); - const W = parseFloat(svg.getAttribute("width")) || vb[2]; - const H = parseFloat(svg.getAttribute("height")) || vb[3]; +/** + * 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 [...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; - }); -} +/** + * 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"; + + if (project.icon) { + const img = document.createElement("img"); + img.src = project.icon; + img.alt = `${project.name} favicon`; + img.loading = "lazy"; + visual.append(img); + } else { + visual.textContent = deriveMonogram(project.name); + } -function draw(lines, count) { - let left = count; - for (const l of lines) { - const {pts, drawn} = l.userData; - if (drawn >= pts.length) continue; + const title = document.createElement("h3"); + title.textContent = project.name; - const n = Math.min(left, pts.length - drawn); - const pos = l.geometry.attributes.position; + const titleGroup = document.createElement("div"); + titleGroup.className = "project-card-title"; + titleGroup.append(visual, title); - for (let i = 0; i < n; i++) { - const p = pts[drawn + i]; - pos.setXYZ(drawn + i, p.x, p.y, 0); - } + 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"; - l.userData.drawn += n; - pos.needsUpdate = true; - l.geometry.setDrawRange(0, l.userData.drawn); + const description = document.createElement("p"); + description.textContent = project.description; + body.append(description); - left -= n; - if (!left) break; + 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)); + card.append(header, body); + return card; } -function projectWorldWidth(projectCamera) { - const h = projectWorldHeight(projectCamera); - return h * projectCamera.aspect; +/** + * 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; } -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; - } +/** + * 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); + }); - segments.forEach(seg => - seg.forEach(p => { - p.x = (p.x - initialCenterX) * xScaleFactor; - p.y = (p.y - initialCenterY) * yScaleFactor; - }) - ); -} + scopedProjects.forEach(project => { + grid.append(buildProjectCard(project)); + }); -/* ───── 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 + layoutBandRows(/** @type {HTMLElement} */ (grid)); }); +} - 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); - }); - } - }; - - window.addEventListener("resize", resizeHandler); +/** + * 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(); } -function startProjectAnimation(canvasId) { - const animation = projectAnimations.get(canvasId); - if (!animation || animation.isAnimating || animation.hasAnimated) return; // Check hasAnimated flag +async function hydrateProjectCatalog() { + try { + const projects = await loadProjectCatalog(); + renderProjectBands(projects); + } catch (error) { + console.error("Failed to render project catalog:", error); + } +} - animation.isAnimating = true; - animation.frameCount = 0; - animation.drawn = 0; +const CARD_WIDTH_PX = 520; +const CARD_GAP_PX = 28; +const MOBILE_BREAKPOINT = 600; +const BAND_ROW_PADDING_PX = 24; + +/** + * Arrange project cards into rows with fixed-width cards: + * - full rows alternate between left and right alignment + * - the final row (even if partial) follows the same alternation pattern + * @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; + } - animation.lines.forEach(line => { - line.userData.drawn = 0; - line.geometry.setDrawRange(0, 0); - }); + grid.innerHTML = ""; - const animate = () => { - if (!animation.isAnimating) return; + 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 computedPerRow = Math.floor((usableWidth + CARD_GAP_PX) / step); + const maxPerRow = Math.max(1, Math.min(2, computedPerRow)); + const total = allCards.length; - if (animation.frameCount <= PROJECT_TOTAL_FRAMES) { - const prog = animation.frameCount / PROJECT_TOTAL_FRAMES; - const ptsToDrawProject = Math.floor(prog * animation.totalPts); + if (total <= maxPerRow) { + const singleRow = document.createElement("div"); + singleRow.className = "band-row band-row-left"; + allCards.forEach(card => singleRow.append(card)); + grid.append(singleRow); + return; + } - draw(animation.lines, ptsToDrawProject - animation.drawn); - animation.drawn = ptsToDrawProject; - animation.frameCount++; + 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"; - requestAnimationFrame(animate); - } else { - animation.isAnimating = false; - animation.hasAnimated = true; // Set flag when animation completes - } + const alignLeft = rowIndex % 2 === 0; - animation.renderer.render(animation.scene, animation.camera); - }; + row.classList.add(alignLeft ? "band-row-left" : "band-row-right"); - animate(); -} + rowCards.forEach(card => row.append(card)); + grid.append(row); -/* ───── Initialization ───── */ -async function initProjectGallery() { - try { - await Promise.all(PROJECT_CANVASES.map(({id, svg}) => initProjectAnimation(id, svg))); - } catch (err) { - console.error("Failed to prepare project animations:", err); - return; + index += maxPerRow; + rowIndex += 1; } - - 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 +265,12 @@ function setupHeroAudioToggle() { document.addEventListener("DOMContentLoaded", () => { setupHeroAudioToggle(); + hydrateProjectCatalog().catch(error => { + console.error("Initialization error:", error); + }); - if (typeof THREE === "undefined") { - console.error("Three.js not loaded"); - return; - } - - initProjectGallery().catch(err => { - console.error("Failed to initialize project gallery:", err); + 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 ac752f5..ee6c3dc 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 { @@ -60,13 +64,14 @@ body { flex-direction: column; align-items: center; gap: 32px; + border-radius: 0; } .hero-media { 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); } @@ -76,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; } @@ -87,6 +93,7 @@ body { height: 100%; object-fit: cover; filter: saturate(1.2) contrast(1.05); + border-radius: 0; z-index: 0; } @@ -193,119 +200,221 @@ body { display: inline; } -/* ---------- Sections ---------- */ -section:not(#top-content-section) { - background: var(--bg-panel); - padding: 50px 40px; - margin: 30px auto; - border-radius: 8px; - border: 1px solid var(--accent-outline); - box-shadow: 0 30px 60px rgba(0, 0, 0, 0.35); +/* ---------- Project Bands ---------- */ +a { + color: var(--accent-gold); + text-decoration: none; + transition: color 0.3s ease; } -h2 { - font-family: "Space Grotesk", sans-serif; - color: var(--text-gold); - margin: 0 0 40px; - text-align: center; - font-size: 2.2em; +a:hover, +a:focus { + color: var(--text-gold-strong); } -ul { - list-style: none; +.bands { + width: 100%; + margin: 0; padding: 0; + display: block; +} + +.band { + position: relative; + border-radius: 0; + border: none; + box-shadow: none; + overflow: hidden; } -li { - padding: 10px 0; - font-size: 1.1em; +.band-inner { + width: 100%; + margin: 0; + padding: clamp(40px, 6vw, 80px) 0; } -a { - color: var(--accent-gold); - text-decoration: none; - transition: 0.3s; +.band-research { + background: var(--band-research); } -a:hover { - color: var(--text-gold-strong); - text-decoration: underline; +.band-tools { + background: var(--band-tools); } -/* ---------- Project Grid ---------- */ -.project-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 30px; +.band-platform { + background: var(--band-platform); +} + +.band-products { + background: var(--band-products); +} + +.band-heading { + position: relative; + z-index: 1; + margin-bottom: 44px; + padding: 0; +} + +.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; + letter-spacing: 0.08em; + color: var(--text-gold); + text-align: left; +} + +.band-grid { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 28px; + padding: 0; +} + +.band-row { + display: flex; + gap: 28px; + width: 100%; + padding: 0 24px; + box-sizing: border-box; +} + +.band-row-left { + justify-content: flex-start; +} + +.band-row-right { + justify-content: flex-end; } .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; + width: 520px; + max-width: 100%; + flex: 0 0 520px; } .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); - position: relative; + font-size: 1.4rem; + font-weight: 600; + color: var(--accent-gold); + overflow: hidden; } -.project-icon-block canvas { +.project-card-visual img { width: 100%; height: 100%; + object-fit: contain; display: block; } -.project-card-content { - padding: 25px; - display: flex; - flex-direction: column; - flex-grow: 1; +.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-card-content h3 { - font-family: "Space Grotesk", sans-serif; - margin: 0; +.status-badge-production { + color: #041c1c; + background: linear-gradient(120deg, #e6ffb2, #8ed26e); + border: none; +} + +.status-badge-beta { + color: #1b1103; + background: linear-gradient(120deg, #ffd18d, #ffae5a); + border: none; +} + +.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 ---------- */ @@ -473,15 +582,7 @@ mpr-footer { .hero-media { width: 100%; - border-radius: 20px; - } - - h2 { - font-size: 1.8em; - } - - .project-grid { - grid-template-columns: 1fr; + border-radius: 0; } .mpr-footer { @@ -521,7 +622,7 @@ mpr-footer { } .hero-media { - border-radius: 16px; + border-radius: 0; } .hero-cta-button { @@ -529,21 +630,23 @@ mpr-footer { text-align: center; } - section:not(#top-content-section) { - padding: 30px 20px; + .band-inner { + padding: 32px 18px 48px; } - h2 { - font-size: 1.6em; - margin-bottom: 30px; + .band-heading h2 { + font-size: 2rem; } - .project-icon-block { - height: 150px; + .band-row { + flex-wrap: wrap; + justify-content: center; } - .project-card-content { + .project-card { padding: 20px; + width: 100%; + flex: 1 0 100%; } .mpr-footer { 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");