Bilingual static site (EN / ES) for Learning Experience Design, UX and Data. Every project has its own URL in both languages.
https://lpedaci.github.io/portfolio/ home EN
https://lpedaci.github.io/portfolio/es/ home ES
https://lpedaci.github.io/portfolio/work/intersafe/ case EN
https://lpedaci.github.io/portfolio/es/work/intersafe/ case ES
Hand-written HTML, CSS and JavaScript, generated by a small Node script. No framework, no runtime dependencies, no build tooling beyond Node itself. Content lives in one source and renders to 24 pages, so a string is written once.
Node 18 or newer, for the build only. The published site does not run Node.
npm run build # writes the HTML, sitemap.xml and robots.txt
npm run serve # builds, then serves the folder
npm run og # redraws the social share cards (needs Chrome installed)index.html, es/, work/, es/work/, 404.html, sitemap.xml and
robots.txt are generated output and are overwritten on every build.
Every page has its own 1200 x 630 card for LinkedIn, X, WhatsApp and Slack:
og-card.jpg for the home page and og-<slug>.jpg for each case study, plus
an -es twin of each. Twenty-two files, all in assets/img/.
They are not hand-exported. src/og/card.mjs is a small page written in the
same tokens as assets/css/site.css, and npm run og paints it with headless
Chrome and encodes it to JPEG. Two kinds share one skeleton, so a link to the
home page and a link to a project read as the same set:
| left column | right | |
|---|---|---|
| identity | monogram, name and role, the years stat | portrait on its sand mat |
| project | category and year, title and subtitle, signature | cover in a 5:3 frame |
Project titles run from 7 to 63 characters, so the display size steps down as the title grows. Covers run from 2.17:1 to square, so the card reuses the 5:3 crop the work grid already applies to them.
Change the palette, the portrait in site.heroPortrait, or any project title,
run npm run og, and the cards follow the site.
npm run og is a separate step from npm run build on purpose: it needs a
browser on the machine, and the cards only change when the brand or the copy
does. All the cards of one language are painted as a single tall strip and
sliced apart, so the whole set takes two browser launches per language and a
few seconds.
LinkedIn caches a link's preview for roughly a week. After deploying, paste the URL into the Post Inspector to force a re-scrape.
src/
data/site.mjs global copy: hero, practice, process, about, contact, links
data/projects.mjs the 11 case studies, in EN and ES
lib/layout.mjs head, header, footer, relative path resolution
lib/pages.mjs home, project page, 404
lib/blocks.mjs case-study block renderers
lib/icons.mjs icon set and the LP monogram
og/card.mjs the 1200 x 630 share card, as a page
og/render.mjs paints the card with headless Chrome (npm run og)
build.mjs writes everything to disk
assets/
css/site.css the design system (tokens in the first 55 lines)
js/site.js filters, mobile menu, deferred embeds, lightbox, reveal
img/ project images and portraits
Every visible string is a { en: '…', es: '…' } pair. A missing language renders
empty.
Projects are objects in src/data/projects.mjs. Array order sets the home grid
order, and the card number (01, 02, …) is derived from it.
{
slug: 'my-project', // sets the URL: /work/my-project/
year: '2026',
cats: ['lxd', 'ux'], // category ids from site.mjs; the first is the card
// label, all of them feed the filters
cover: 'my-cover.jpg', // file inside assets/img/
coverAlt: { en: '…', es: '…' },
title: { en: '…', es: '…' },
subtitle: { en: '…', es: '…' },
summary: { en: '…', es: '…' }, // card copy and meta description
tags: ['Figma', 'SCORM'],
facts: {
role: { en: '…', es: '…' },
timeline: { en: '…', es: '…' },
context: { en: '…', es: '…' },
stack: ['Figma', 'iSpring'],
methods: ['ADDIE', 'Kirkpatrick']
},
links: [{ label: { en: 'Live site', es: 'Sitio en vivo' }, url: 'https://…' }],
blocks: [ /* see below */ ],
outcome: { h: { en: 'Outcomes', es: 'Resultados' }, body: { en: '…', es: '…' } }
}t |
renders |
|---|---|
prose |
heading plus paragraphs |
list |
items with a bold term and an explanation |
roadmap |
numbered phases (E01, F01, M01…), two columns on desktop |
callout |
a principle or standout idea, on a sage ground |
stats |
three or more large figures |
cases |
case cards with their own links |
embed |
video, audio, document or interactive piece |
live |
a real site embedded inside a browser frame |
gallery |
images with a lightbox (wide: true for a single column) |
{ t: 'live',
h: { en: 'Unit 2, live', es: 'Unidad 2, en vivo' },
src: 'https://lpedaci.github.io/so_unidad-02/',
url: 'https://lpedaci.github.io/so_unidad-02/',
label: { en: 'Unit 2 site', es: 'Sitio de la Unidad 2' } },
{ t: 'embed', kind: 'video', // video | audio | doc | interactive
h: { en: 'Watch', es: 'Ver' },
src: 'https://player.vimeo.com/video/1025598739', // the embed URL
host: 'Vimeo',
label: { en: 'Play', es: 'Reproducir' },
open: 'https://vimeo.com/1025598739' } // the normal URL, for a new tabNo embed loads until it is clicked. Until then the page holds a light frame, so a case study with ten videos still paints fast.
Tokens live in the first 55 lines of assets/css/site.css and drive the whole site.
| Paper | #F7F4EC ground · #FDFBF6 cards · #ECE5D7 bands |
| Ink | #17150F |
| Clay | #965E44, the editorial voice: eyebrows, the stressed word, selection |
| Sage | #596959, data and behaviour: figures, hovers, selected states |
| Sand | #E2BB99 |
| Headlines | Archivo, lowercase, tight tracking |
| Long-form text | Newsreader (serif) |
| Labels and figures | JetBrains Mono |
Implementation notes:
- Grid cards are
aspect-ratio: 5 / 3withobject-fit: cover, so covers are cropped. - The hero panel has two modes, switched by
heroPortraitinsrc/data/site.mjs: a full-bleed portrait on a clay mat (--matsets the mat width,object-positionthe crop), or a cropped circular portrait with the orbit ring and watermark. - Circles biting into panel edges use a ring in the background colour
(
box-shadow: 0 0 0 9px var(--bone)), not real cutouts. - The paper grain is an SVG texture on the
bodybackground, not an overlay layer. - Line measures (
max-widthinch) sit on the text element, not its container, sincechresolves against the element's ownfont-size. - Z-index scale: skip link 10000, masthead 200, mobile sheet 150. The sheet stays under the masthead so the button that closes it remains reachable.
- All content is visible without JavaScript. Entrance animations turn on only once
the browser confirms JS runs (a
jsclass on<html>). Anything already inside the viewport at first paint is resolved synchronously and without a fade, so the reveal never delays the h1. A CSS keyframe releases anything left after 4 s, so a stylesheet that arrives without its script cannot leave the page blank. prefers-reduced-motionis honoured.- Keyboard navigation, visible focus, skip to content,
aria-currenton the language switch. - The mobile sheet is a real modal:
role="dialog",aria-modal, a Tab cycle that includes the burger, and focus restored on every close path. - Filtering the work grid announces its new result count through a
role="status"message that is visible as well as spoken. - Controls are outlined with
--line-ctrl, measured at 3:1 against both grounds; the lighter--lineis reserved for decorative rules, where 1.4.11 does not apply. - Every interactive target is at least 44 px on its constrained axis.
loading="lazy"on images, deferred embeds, no external libraries.- Print stylesheet: case studies print legibly.
GitHub Pages serves the repository from the root, so a push publishes. .nojekyll
stops Jekyll from processing the output, and 404.html uses absolute URLs so it
renders correctly from any broken path.
Canonicals, hreflang, Open Graph and the sitemap are all derived from site.domain
in src/data/site.mjs. Internal links are relative, so the site works under
/portfolio/ and at the root of a domain alike.