From 36084013c8added5735acd86a23e73c0c369da24 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 15 May 2026 13:37:09 -0700 Subject: [PATCH] Initial commit: dlstack file-driven link index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hand-rolled linktree alternative — pure static HTML/CSS/JS, no build step. Drop on any shared host via SFTP and edit data/links.json to update. Features - File-driven content via data/links.json (links, projects, YouTube, client tiles, portfolio pieces) - Two interchangeable templates: editorial (Fraunces + paper + vermilion) and swiss (Archivo grotesque, all-caps poster) - Auto/light/dark theme toggle with no-flash boot script - Auto-fetched favicons via Google S2 (with image-URL override) - Lazy YouTube facades (no third-party JS until clicked) - Adaptive client-logo grid - Scroll-triggered reveal animations - ~40 KB total payload, ~12 KB gzipped The repo ships links.example.json as a demo; data/links.json is gitignored so personal content stays out of the public repo. --- .gitignore | 20 ++ README.md | 377 ++++++++++++++++++++++++++++++ assets/css/styles.css | 499 ++++++++++++++++++++++++++++++++++++++++ assets/css/swiss.css | 407 ++++++++++++++++++++++++++++++++ assets/js/app.js | 303 ++++++++++++++++++++++++ data/links.example.json | 102 ++++++++ favicon.svg | 5 + index.html | 54 +++++ 8 files changed, 1767 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/css/styles.css create mode 100644 assets/css/swiss.css create mode 100644 assets/js/app.js create mode 100644 data/links.example.json create mode 100644 favicon.svg create mode 100644 index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d68e740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +node_modules/ +.env +.env.local +*.log + +# Claude/MCP local state +.claude/ +.claude-flow/ +.mcp.json +CLAUDE.md + +# Personal content — only the example file is committed +data/links.json + +# Personal media — your own portfolio/logo images +assets/img/ + +# Stray empty PHP shell sometimes created by local dev servers +index.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8b2fe5 --- /dev/null +++ b/README.md @@ -0,0 +1,377 @@ +# dlstack + +A hand-built, file-driven personal link index. Drop it on any shared host +via SFTP — no build step, no framework, no database. To update the page, +edit `data/links.json` and re-upload. + +Live at . + +## Setup + +The repo ships with `data/links.example.json` (Ada Lovelace placeholder +data). The app reads `data/links.json` first, and falls back to the +example if there's no real file — so a fresh clone renders immediately. + +To customize: + +```bash +cp data/links.example.json data/links.json +# edit data/links.json — your real content +``` + +`data/links.json` is gitignored, so your personal content stays out of +the public repo. Upload `data/links.json` to your host alongside the +other files when you deploy. + +## File layout + +``` +dlstack/ +├── index.html shell + font preloads + no-flash boot script +├── favicon.svg the dL mark, ink/paper adaptive +├── data/ +│ ├── links.example.json committed example — used as fallback +│ └── links.json your real content (gitignored) +└── assets/ + ├── css/ + │ ├── styles.css editorial template (default) + │ └── swiss.css swiss template (overrides, scoped) + └── js/ + └── app.js loader + renderers +``` + +Total payload: ~40 KB across all assets (uncompressed). Gzipped: ~12 KB. + +## Deploying + +Upload the whole directory via SFTP/FTP to the web root of any shared host. +That's it — no PHP, no Node, no DB. + +To update content: edit `data/links.json` locally, upload, done. + +If you're behind Cloudflare during active development, enable +**Caching → Configuration → Development Mode** in the CF dashboard to +bypass the edge cache for 3 hours. Otherwise purge edge cache after each +asset change. + +## `data/links.json` schema + +```jsonc +{ + "profile": { … }, // who you are + "theme": { … }, // colors + which template + "sections": [ … ], // groups of items + "social": [ … ], // pill buttons under the hero + "footer": { … } // bottom strip +} +``` + +### `profile` + +| field | type | description | +|-----------|--------|---------------------------------------------------| +| `name` | string | Hero name. Last word is set in italic accent. | +| `handle` | string | Optional. Shown in the top marker (e.g. `@you`). | +| `tagline` | string | Italic subtitle. Words separated by `·` get the accent color. | +| `bio` | string | One-paragraph intro under the tagline. | +| `location`| string | Currently unused after the live clock was removed; safe to leave or omit. | + +### `theme` + +| field | type | values | description | +|------------|--------|---------------------------------|-------------| +| `accent` | string | hex color, e.g. `"#E8482C"` | Single sharp accent color used throughout. | +| `template` | string | `"editorial"` (default) or `"swiss"` | Visual treatment. See [Templates](#templates). | +| `mode` | string | unused; kept for future use | Theme toggle (auto/light/dark) is controlled by the corner pill, not this field. | + +### `sections` + +An ordered array of groups. Each section renders with a numbered masthead +(`№ 01 / Sites`) and a grid of items. + +```json +{ + "id": "sites", // required, unique slug + "label": "Sites", // required, the section title + "kicker": "Where I live online", // optional italic tagline + "layout": "clients", // optional — see Layouts below + "items": [ … ] // required, array of items +} +``` + +#### Layouts + +| `layout` | When to use | Grid behavior | +|--------------|----------------------------------------|----------------------------------------| +| *omitted* | Default bento mix | 12-column asymmetric grid (link cards span 4/12, projects/youtube span 6/12, featured spans 8/12, portfolio spans 12/12) | +| `"clients"` | Logo wall of clients/partners | Auto-flowing square tiles, `minmax(108–124px, 1fr)` | + +`layout: "clients"` is also auto-detected if the first item in the section +has `type: "client"`. + +### Items + +Every item lives in a section's `items` array and has a `type` field. The +five available types are documented below. + +#### `type: "link"` — standard link card + +```json +{ + "type": "link", + "title": "Columinate", + "url": "https://columinate.com", + "description": "Cooperative Technology Solutions", + "image": "https://example.com/logo.png", // optional — overrides auto-favicon + "featured": false // optional +} +``` + +| field | required? | notes | +|---------------|-----------|-------| +| `title` | yes | Card headline. | +| `url` | yes | Where the card links to. Opens in new tab. | +| `description` | no | Smaller line under the title. | +| `image` | no | Custom logo URL. Fills the favicon slot edge-to-edge (`object-fit: cover`). If omitted, a favicon is auto-fetched via Google's S2 service (`google.com/s2/favicons?sz=128`). | +| `icon` | no | Alias for `image`. | +| `featured` | no | Reserved; not currently styled differently for link cards. | + +#### `type: "card"` — project card + +Bigger, prose-friendly card with tags. + +```json +{ + "type": "card", + "title": "CoVote", + "url": "https://covote.org", + "description": "Anonymous election platform for co-ops.", + "tags": ["democracy", "co-ops"], + "featured": true // optional — makes it the hero card +} +``` + +| field | required? | notes | +|---------------|-----------|-------| +| `title` | yes | Serif headline (Fraunces in editorial; Archivo 900 caps in Swiss). | +| `url` | no | If present, the card becomes a link. | +| `description` | no | Paragraph under the title. | +| `tags` | no | Array of strings — small monospaced chips. | +| `featured` | no | When `true`, card spans 8/12 columns AND fills with the accent color (paper text on vermilion). Use sparingly — one per section. | + +#### `type: "youtube"` — embedded video + +Renders a lazy facade (thumbnail + play button). The real YouTube iframe +only loads when clicked, so the page stays fast even with many videos. + +```json +{ + "type": "youtube", + "id": "42EXoE7pNAc", + "title": "The 7 Cooperative Principles Song" +} +``` + +| field | required? | notes | +|---------|-----------|-------| +| `id` | yes | YouTube video ID (the `v=` parameter from the URL). | +| `title` | yes | Shown over the thumbnail and as the iframe's accessible title. | + +#### `type: "client"` — logo tile + +Used in client/partner walls. Square logo tile with a small caption +below. Best in a section with `layout: "clients"`. + +```json +{ + "type": "client", + "title": "Columinate", + "url": "https://columinate.coop", + "image": "https://columinate.coop/i/logo-square.png" // optional +} +``` + +| field | required? | notes | +|---------|-----------|-------| +| `title` | yes | Caption below the tile. Long names wrap inside the column width. | +| `url` | no | If present, the tile becomes a link. | +| `image` | no | Custom logo. If omitted, an auto-favicon is used (rendered at 60% of the tile, contained). | +| `icon` | no | Alias for `image`. | + +#### `type: "portfolio"` — wide design/showcase + +Always spans the full row. Use for featured design pieces, case-study +images, or any visual you want to feature large. + +```json +{ + "type": "portfolio", + "title": "Brand identity — Acme Co-op", + "url": "https://example.com/case-study", + "image": "https://example.com/portfolio/acme-hero.jpg", + "description": "Logo system, packaging, signage", + "ratio": "5:2" // optional, default "5:2" +} +``` + +| field | required? | notes | +|---------------|-----------|-------| +| `image` | yes | The image to display. Fills the card with `object-fit: cover`. | +| `title` | no | Caption headline below the image. | +| `description` | no | Caption subline. | +| `url` | no | If present, the card becomes a link. | +| `ratio` | no | Override aspect ratio. Format `"W:H"` (e.g. `"16:9"`, `"3:1"`, `"4:3"`). Default `"5:2"` (wide, not tall). | + +### `social` + +Pill buttons that appear under the hero bio. + +```json +[ + { "label": "GitHub", "url": "https://github.com/you", "icon": "github" }, + { "label": "LinkedIn", "url": "https://linkedin.com/in/you", "icon": "linkedin" }, + { "label": "Email", "url": "mailto:you@example.com", "icon": "mail" }, + { "label": "RSS", "url": "/feed.xml", "icon": "rss" } +] +``` + +| field | required? | notes | +|---------|-----------|-------| +| `label` | yes | Text shown next to the icon. | +| `url` | yes | Link target. | +| `icon` | no | One of the built-in icon names (see below). Unknown values fall back to a generic chain-link icon. | + +**Built-in icons:** `github`, `linkedin`, `mail`, `rss`, `link`. + +The icons are not from a library — they're hand-coded inline SVGs in the +`SOCIAL_ICONS` object inside `assets/js/app.js`. Inline because: + +- No extra HTTP requests +- Tint cleanly with `currentColor` so they inherit the link's text color + (works in both editorial + Swiss templates, light + dark modes) +- Total weight for all 5 is ~2 KB + +#### Where to find more icons + +| Source | Best for | URL | +|---|---|---| +| **Simple Icons** | Brand logos — GitHub, Mastodon, Bluesky, YouTube, Instagram, X/Twitter, Substack, Discord, Patreon, Buy Me a Coffee, etc. (3000+ brands) | | +| **Lucide** | Generic UI icons — mail, rss, link, phone, calendar, etc. (1500+, MIT-licensed) | | +| **Phosphor** | 9000+ icons with multiple weights (regular / bold / thin / duotone) | | +| **Heroicons** | Tailwind's set, clean and minimal | | + +For social/brand logos → **Simple Icons**. For generic UI marks (mail, +rss, link, etc.) → **Lucide** or **Phosphor**. + +#### How to add a new icon + +1. Find the icon on one of the sites above. +2. Click it and choose **"Copy SVG"** (or download the SVG). +3. Open `assets/js/app.js` and find the `SOCIAL_ICONS` object. +4. Paste the SVG as a new entry, keyed by a short name. Strip any + hardcoded `fill="..."` or `stroke="..."` colors and replace them with + `currentColor` so it inherits the link color. + + ```js + const SOCIAL_ICONS = { + github: '', + linkedin: '', + mail: '', + rss: '', + link: '', + + // Newly added — Bluesky from Simple Icons: + bluesky: '' + }; + ``` + +5. Reference the new icon by its key in `data/links.json`: + + ```json + { "label": "Bluesky", "url": "https://bsky.app/profile/you", "icon": "bluesky" } + ``` + +6. Upload `app.js` and `links.json` (and purge Cloudflare cache or use + Development Mode). + +The fallback for any unknown `icon` value is a generic chain-link icon, +so it'll still render even if you forget step 4. + +### `footer` + +```json +{ + "copy": "Hand-built. No trackers.", + "year": "auto" +} +``` + +| field | required? | notes | +|--------|-----------|-------| +| `copy` | no | Left side of the footer. | +| `year` | no | Currently unused — the right side auto-displays the current year and your name. | + +## Templates + +`theme.template` selects the visual treatment. Both stylesheets are +always loaded, but the Swiss rules are scoped to +`:root[data-template="swiss"]` so only one is "active" at a time. + +### `"editorial"` (default) + +Magazine/poster aesthetic. + +- **Type:** Fraunces (variable serif, italic for accents) + Geist (sans body) + Geist Mono (UI/eyebrows) +- **Mood:** warm paper background, vermilion accent, subtle grain texture, asymmetric bento grid, magazine-style section numbers, italic last-name in the hero, floating typographic asterism +- **Cards:** rounded corners (14px), thin rules, lift + corner-arrow on hover + +### `"swiss"` + +Müller-Brockmann tribute. Akzidenz/Univers spirit with [Archivo](https://fonts.google.com/specimen/Archivo) as the free variable substitute. + +- **Type:** Archivo only, weights 400/500/600/700/900, all-caps headlines +- **Mood:** near-pure white paper, near-pure black ink, true Swiss red (`#DC2127` by default, overridable via `theme.accent`) +- **Geometry:** zero border-radius everywhere, 2–4px black rules, solid red disc replaces the typographic asterism +- **Cards:** invert on hover (white → solid black with paper text). Featured cards are solid red, invert to black on hover. +- **Animations:** no spin, no italic — only sharp pop-in for the geometric mark + +To switch: + +```json +"theme": { "accent": "#E8482C", "template": "swiss" } +``` + +Choice is also cached in `localStorage` (`dlstack-template`) so reloads +don't flash the wrong template before JSON parses. + +## Theme (light / dark / auto) + +A small pill in the top-right cycles **Auto → Light → Dark → Auto** on +click. "Auto" follows `prefers-color-scheme`. Choice persists in +`localStorage` (`dlstack-theme`). An inline boot script in `` +applies the saved choice before CSS paint, so there's no flash on reload. + +The accent color (`theme.accent` in JSON) is applied as a CSS custom +property at runtime and respects both light and dark modes. + +## Reserved sub-paths + +- `data/links.json` — content +- `assets/css/styles.css` — editorial template + base +- `assets/css/swiss.css` — swiss template overrides +- `assets/js/app.js` — renderer +- `favicon.svg` — the dL mark + +Don't rename these without updating the references in `index.html`. + +## Local preview + +From the project root: + +```bash +python3 -m http.server 8765 +# then open http://127.0.0.1:8765 +``` + +Any static file server works; CORS isn't an issue since everything is +same-origin. diff --git a/assets/css/styles.css b/assets/css/styles.css new file mode 100644 index 0000000..64f135c --- /dev/null +++ b/assets/css/styles.css @@ -0,0 +1,499 @@ +/* dlstack — bolder + Editorial poster aesthetic. Massive Fraunces, vermilion that owns the page. +*/ + +*, *::before, *::after { box-sizing: border-box; } +* { margin: 0; } +html, body { height: 100%; } +img, svg { display: block; max-width: 100%; } +a { color: inherit; text-decoration: none; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; } + +:root { + --paper: oklch(0.972 0.012 78); + --paper-2: oklch(0.945 0.014 78); + --ink: oklch(0.190 0.020 268); + --ink-2: oklch(0.305 0.020 268); + --muted: oklch(0.520 0.014 268); + --rule: oklch(0.860 0.010 78); + --accent: #E8482C; + --on-accent: oklch(0.980 0.014 78); + + --display: "Fraunces", ui-serif, Georgia, serif; + --body: "Geist", ui-sans-serif, system-ui, sans-serif; + --mono: "Geist Mono", ui-monospace, Menlo, monospace; + + --fs-mini: clamp(0.72rem, 0.70rem + 0.10vw, 0.82rem); + --fs-sm: clamp(0.88rem, 0.85rem + 0.15vw, 0.95rem); + --fs-md: clamp(1rem, 0.96rem + 0.20vw, 1.10rem); + --fs-lg: clamp(1.30rem, 1.18rem + 0.50vw, 1.65rem); + --fs-xl: clamp(1.60rem, 1.30rem + 1.20vw, 2.40rem); + --fs-tag: clamp(1.50rem, 1.10rem + 1.80vw, 2.80rem); + --fs-hero: clamp(4rem, 1.50rem + 13vw, 13rem); + --fs-num: clamp(2.50rem, 1.80rem + 3.00vw, 4.50rem); + + --gutter: clamp(1.25rem, 0.75rem + 2.5vw, 3rem); + --max: 78rem; + --radius: 14px; + --ease: cubic-bezier(0.22, 1, 0.36, 1); + --ease-strong: cubic-bezier(0.16, 1, 0.30, 1); +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="auto"] { + --paper: oklch(0.155 0.018 268); + --paper-2: oklch(0.205 0.020 268); + --ink: oklch(0.97 0.012 78); + --ink-2: oklch(0.85 0.014 78); + --muted: oklch(0.62 0.018 268); + --rule: oklch(0.30 0.020 268); + } +} +:root[data-theme="dark"] { + --paper: oklch(0.155 0.018 268); + --paper-2: oklch(0.205 0.020 268); + --ink: oklch(0.97 0.012 78); + --ink-2: oklch(0.85 0.014 78); + --muted: oklch(0.62 0.018 268); + --rule: oklch(0.30 0.020 268); +} + +html { font-family: var(--body); color: var(--ink); background: var(--paper); } +body { + font-size: var(--fs-md); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + position: relative; + overflow-x: hidden; +} +/* paper grain — very subtle */ +body::before { + content: ""; position: fixed; inset: 0; + pointer-events: none; z-index: 0; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.6; mix-blend-mode: multiply; +} +::selection { background: var(--accent); color: var(--on-accent); } + +.shell { + position: relative; z-index: 1; + max-width: var(--max); + margin: 0 auto; + padding: 0 var(--gutter) 6rem; +} + +/* ───── marker bar ───── */ +.marker { + display: flex; align-items: center; gap: 1rem; + padding: 1.25rem 0; + border-bottom: 1px solid var(--rule); + font: 500 var(--fs-mini)/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--muted); +} +.marker__brand { display: inline-flex; align-items: center; gap: 0.6rem; color: var(--accent); margin-right: auto; } +.marker__brand .star { font-size: 1.1em; line-height: 0; transform: translateY(1px); } +.marker__year { font-variant-numeric: tabular-nums; } + +/* theme pill — sits in the marker bar */ +.theme { + padding: 5px 12px; + background: transparent; + border: 1px solid var(--rule); + border-radius: 999px; + font: 500 var(--fs-mini)/1.4 var(--mono); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.14em; + cursor: pointer; + transition: border-color 220ms var(--ease), color 220ms var(--ease), background 220ms var(--ease); +} +.theme:hover { border-color: var(--ink); color: var(--ink); background: color-mix(in oklch, var(--ink) 4%, transparent); } + +/* ───── hero ───── */ +.hero { + position: relative; + padding: clamp(3rem, 8vw, 7rem) 0 clamp(3rem, 6vw, 5rem); +} +.hero__name { + font-family: var(--display); + font-weight: 320; + font-variation-settings: "opsz" 144, "SOFT" 30; + font-size: var(--fs-hero); + line-height: 0.86; + letter-spacing: -0.045em; + color: var(--ink); +} +.hero__name em { + font-style: italic; + font-variation-settings: "opsz" 144, "SOFT" 100, "WONK" 1; + color: var(--accent); +} +.hero__asterism { + position: absolute; + top: clamp(2rem, 7vw, 5rem); + right: clamp(0.5rem, 3vw, 2rem); + font-family: var(--display); + font-size: clamp(3rem, 8vw, 6rem); + color: var(--accent); + line-height: 1; + pointer-events: none; + transform: rotate(-8deg); + opacity: 0.95; +} +.hero__tagline { + margin-top: clamp(1.5rem, 3vw, 2.25rem); + font-family: var(--display); + font-style: italic; + font-weight: 400; + font-variation-settings: "opsz" 60, "SOFT" 100; + font-size: var(--fs-tag); + line-height: 1.1; + color: var(--ink-2); + letter-spacing: -0.015em; + max-width: 24ch; +} +.hero__tagline span { color: var(--accent); padding: 0 0.2em; } +.hero__bio { + margin-top: 1.5rem; + max-width: 48ch; + color: var(--muted); + font-size: var(--fs-md); +} + +/* ───── social rail ───── */ +.social { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 2rem; } +.social a { + display: inline-flex; align-items: center; gap: 8px; + padding: 9px 16px; + border: 1px solid var(--ink); + border-radius: 999px; + font-size: var(--fs-sm); + font-weight: 500; + color: var(--ink); + background: transparent; + transition: background 220ms var(--ease-strong), color 220ms var(--ease-strong), transform 220ms var(--ease); +} +.social a:hover { background: var(--ink); color: var(--paper); transform: translateY(-2px); } +.social svg { width: 16px; height: 16px; } + +/* ───── sections ───── */ +.section { margin-top: clamp(4rem, 7vw, 6rem); } +.section__head { + display: grid; + grid-template-columns: auto 1fr; + gap: clamp(1rem, 2.5vw, 2rem); + align-items: end; + padding-bottom: 1rem; + margin-bottom: clamp(1.5rem, 3vw, 2.25rem); + border-bottom: 2px solid var(--ink); +} +.section__numwrap { display: flex; flex-direction: column; gap: 0.2rem; align-self: end; } +.section__numwrap small { + font-family: var(--mono); + font-size: var(--fs-mini); + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--accent); +} +.section__num { + font-family: var(--display); + font-weight: 350; + font-variation-settings: "opsz" 144; + font-size: var(--fs-num); + line-height: 0.85; + color: var(--ink); + font-feature-settings: "lnum" on, "tnum" on; + letter-spacing: -0.02em; +} +.section__titlewrap { + display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; + gap: 0.75rem 1.5rem; +} +.section__title { + font-family: var(--display); + font-weight: 350; + font-variation-settings: "opsz" 144, "SOFT" 30; + font-size: var(--fs-xl); + line-height: 0.95; + letter-spacing: -0.025em; + color: var(--ink); +} +.section__kicker { + font-family: var(--display); + font-style: italic; + color: var(--muted); + font-size: var(--fs-md); + font-variation-settings: "opsz" 24; +} + +/* ───── bento grid ───── */ +.grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 1rem; +} +.grid > * { grid-column: span 12; } +@media (min-width: 600px) { + .grid > .card--link { grid-column: span 6; } + .grid > .card--project { grid-column: span 6; } + .grid > .card--youtube { grid-column: span 6; } +} +@media (min-width: 960px) { + .grid > .card--link { grid-column: span 4; } + .grid > .card--project { grid-column: span 6; } + .grid > .card--youtube { grid-column: span 6; } + .grid > .card--featured{ grid-column: span 8; } +} + +/* portfolio item — always full row */ +.grid > .card--portfolio { grid-column: span 12; } + +/* clients grid — auto-flowing square tiles */ +.grid--clients { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: clamp(0.75rem, 1.4vw, 1.25rem); +} +@media (min-width: 1100px) { + .grid--clients { grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); } +} + +/* ───── cards (base) ───── */ +.card { + position: relative; + padding: 1.1rem 1.15rem; + border: 1px solid var(--rule); + border-radius: var(--radius); + background: var(--paper); + transition: transform 320ms var(--ease-strong), border-color 220ms var(--ease), box-shadow 320ms var(--ease); + display: flex; flex-direction: column; gap: 0.5rem; + isolation: isolate; +} +.card:hover { + transform: translateY(-3px); + border-color: var(--ink-2); + box-shadow: 0 1px 0 rgba(20,18,34,0.04), 0 18px 36px -22px rgba(20,18,34,0.30); +} +.card::after { + content: "→"; + position: absolute; top: 14px; right: 16px; + font-family: var(--mono); font-size: 14px; + color: var(--muted); + transition: transform 320ms var(--ease-strong), color 220ms var(--ease); +} +.card:hover::after { transform: translate(3px, -3px); color: var(--accent); } + +/* link card */ +.card--link { display: grid; grid-template-columns: 64px 1fr; gap: 1rem; align-items: start; padding: 1.15rem; padding-right: 2.5rem; } +.card--link .favicon { + width: 64px; height: 64px; border-radius: 12px; + background: var(--paper-2); + display: grid; place-items: center; + border: 1px solid var(--rule); + overflow: hidden; +} +.card--link .favicon img { width: 100%; height: 100%; object-fit: cover; } +.card--link .favicon img.is-favicon { width: 44px; height: 44px; object-fit: contain; } +.card--link .favicon[data-fallback] { font-family: var(--display); font-weight: 500; font-size: 1.7rem; color: var(--ink); } +.card__title { font-weight: 540; letter-spacing: -0.005em; line-height: 1.25; } +.card__desc { color: var(--muted); font-size: var(--fs-sm); } +.card__host { font-family: var(--mono); font-size: var(--fs-mini); color: var(--muted); margin-top: 0.2rem; text-transform: lowercase; } + +/* project card */ +.card--project { padding: 1.5rem; gap: 0.75rem; } +.card--project .card__title { + font-family: var(--display); + font-weight: 380; + font-variation-settings: "opsz" 60; + font-size: var(--fs-lg); + letter-spacing: -0.02em; + line-height: 1.05; +} + +/* FEATURED — accent-filled hero card */ +.card--featured { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + padding: 2rem 2rem 1.75rem; + min-height: clamp(220px, 28vw, 320px); + justify-content: space-between; + overflow: hidden; +} +.card--featured::before { + content: "★ Featured"; + position: absolute; top: 1.25rem; left: 2rem; + font-family: var(--mono); font-size: var(--fs-mini); + text-transform: uppercase; letter-spacing: 0.2em; + color: color-mix(in oklch, var(--on-accent) 75%, transparent); +} +.card--featured::after { color: color-mix(in oklch, var(--on-accent) 60%, transparent); } +.card--featured:hover { transform: translateY(-3px); border-color: var(--accent); box-shadow: 0 1px 0 rgba(20,18,34,0.04), 0 24px 44px -22px color-mix(in oklch, var(--accent) 70%, transparent); } +.card--featured:hover::after { color: var(--on-accent); } +.card--featured .card__title { + font-size: clamp(1.85rem, 1.30rem + 1.6vw, 2.6rem); + font-weight: 380; + font-variation-settings: "opsz" 144, "SOFT" 30; + letter-spacing: -0.025em; + color: var(--on-accent); + margin-top: 2rem; + line-height: 1; +} +.card--featured .card__desc { color: color-mix(in oklch, var(--on-accent) 80%, transparent); font-size: var(--fs-md); max-width: 38ch; } +.card--featured .tag { + background: color-mix(in oklch, var(--on-accent) 12%, transparent); + border-color: color-mix(in oklch, var(--on-accent) 22%, transparent); + color: var(--on-accent); +} + +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 0.5rem; } +.tag { + font-family: var(--mono); font-size: var(--fs-mini); + padding: 3px 9px; border-radius: 5px; + background: var(--paper-2); + color: var(--ink-2); + border: 1px solid var(--rule); +} + +/* portfolio — wide landscape with image + caption below */ +.card--portfolio { padding: 0; overflow: hidden; gap: 0; } +.card--portfolio::after { + top: auto; bottom: 14px; right: 16px; + color: var(--paper); + text-shadow: 0 1px 8px rgba(0,0,0,0.4); + z-index: 2; +} +.portfolio__media { + position: relative; + width: 100%; + aspect-ratio: var(--portfolio-ratio, 5 / 2); + background: var(--paper-2); + overflow: hidden; +} +.portfolio__media img { + width: 100%; height: 100%; + object-fit: cover; + transition: transform 700ms var(--ease-strong); +} +.card--portfolio:hover .portfolio__media img { transform: scale(1.025); } +.portfolio__caption { + padding: 1rem 1.25rem 1.1rem; + display: flex; flex-direction: column; + gap: 0.25rem; + border-top: 1px solid var(--rule); +} +.portfolio__caption .card__title { + font-family: var(--display); + font-weight: 380; + font-variation-settings: "opsz" 60; + font-size: var(--fs-lg); + letter-spacing: -0.02em; + line-height: 1.1; + text-transform: none; +} +.portfolio__caption .card__desc { font-size: var(--fs-sm); } + +/* client tile — square logo, caption below, no card chrome */ +.card--client { + padding: 0; + border: 0; + background: transparent; + display: flex; flex-direction: column; + gap: 0.55rem; + align-items: stretch; + text-align: center; + min-width: 0; +} +.card--client::after { display: none; } +.card--client:hover { transform: none; box-shadow: none; } +.client__logo { + position: relative; + aspect-ratio: 1; + width: 100%; + border-radius: 14px; + background: var(--paper-2); + border: 1px solid var(--rule); + overflow: hidden; + display: grid; place-items: center; + transition: transform 320ms var(--ease-strong), border-color 220ms var(--ease), box-shadow 320ms var(--ease); +} +.card--client:hover .client__logo { + transform: translateY(-3px); + border-color: var(--ink-2); + box-shadow: 0 1px 0 rgba(20,18,34,0.04), 0 14px 28px -18px rgba(20,18,34,0.28); +} +.client__logo img.is-favicon { width: 60%; height: 60%; object-fit: contain; } +.client__logo img:not(.is-favicon) { width: 100%; height: 100%; object-fit: cover; } +.client__logo[data-fallback] { font-family: var(--display); font-weight: 500; font-size: 2.2rem; color: var(--ink); } +.client__title { + font-size: var(--fs-sm); + color: var(--ink-2); + line-height: 1.25; + letter-spacing: -0.005em; + /* width is naturally constrained by the grid track (= image width) */ + word-break: break-word; + hyphens: auto; +} + +/* youtube */ +.card--youtube { padding: 0; overflow: hidden; } +.yt { position: relative; aspect-ratio: 16 / 9; background: #000 center / cover no-repeat; cursor: pointer; display: block; } +.yt::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 35%, rgba(0,0,0,0.65) 100%); } +.yt__play { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + width: 72px; height: 72px; border-radius: 50%; + background: rgba(0,0,0,0.55); border: 1.5px solid rgba(255,255,255,0.85); + display: grid; place-items: center; + transition: transform 320ms var(--ease-strong), background 220ms var(--ease), border-color 220ms var(--ease); +} +.yt:hover .yt__play { transform: translate(-50%, -50%) scale(1.08); background: var(--accent); border-color: var(--accent); } +.yt__play svg { width: 26px; height: 26px; fill: #fff; margin-left: 3px; } +.yt__title { + position: absolute; left: 16px; right: 16px; bottom: 14px; + color: #fff; font-family: var(--display); font-weight: 400; + font-size: var(--fs-md); line-height: 1.2; + z-index: 1; text-shadow: 0 1px 12px rgba(0,0,0,0.6); +} +.yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; } + +/* footer */ +.foot { + margin-top: clamp(4rem, 8vw, 7rem); + padding-top: 1.5rem; + border-top: 1px solid var(--rule); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 1rem; + color: var(--muted); + font-family: var(--mono); font-size: var(--fs-mini); + text-transform: uppercase; letter-spacing: 0.18em; +} +.foot__mark { + font-family: var(--display); font-style: italic; font-size: 1.4rem; + color: var(--accent); text-transform: none; letter-spacing: 0; + line-height: 1; +} +.foot__right { text-align: right; } + +/* reveal */ +.reveal { opacity: 0; transform: translateY(16px); transition: opacity 700ms var(--ease-strong), transform 700ms var(--ease-strong); } +.reveal.in { opacity: 1; transform: none; } + +/* hero entrance */ +.hero__name { animation: rise 1100ms var(--ease-strong) both; } +.hero__asterism { animation: spin-in 1400ms 200ms var(--ease-strong) both; } +.hero__tagline { animation: rise 1100ms 150ms var(--ease-strong) both; } +.hero__bio { animation: rise 1100ms 250ms var(--ease-strong) both; } +.social { animation: rise 1100ms 350ms var(--ease-strong) both; } +@keyframes rise { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } } +@keyframes spin-in { from { opacity: 0; transform: rotate(-60deg) scale(0.5); } to { opacity: 0.95; transform: rotate(-8deg) scale(1); } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } + .reveal { opacity: 1; transform: none; } +} + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } diff --git a/assets/css/swiss.css b/assets/css/swiss.css new file mode 100644 index 0000000..c606610 --- /dev/null +++ b/assets/css/swiss.css @@ -0,0 +1,407 @@ +/* dlstack — Swiss/International template + Activate with theme.template: "swiss" in links.json. + Tribute to Müller-Brockmann, Hofmann, Lohse — Akzidenz-Grotesk family + (Archivo as a free variable substitute). All overrides scoped to + :root[data-template="swiss"] so this file is inert otherwise. +*/ + +:root[data-template="swiss"] { + --paper: oklch(0.985 0 0); + --paper-2: oklch(0.955 0 0); + --ink: oklch(0.10 0 0); + --ink-2: oklch(0.18 0 0); + --muted: oklch(0.42 0 0); + --rule: oklch(0.10 0 0); + --accent: #DC2127; + --on-accent: oklch(0.99 0 0); + --display: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif; + --body: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif; + --mono: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif; + --radius: 0; +} + +@media (prefers-color-scheme: dark) { + :root[data-template="swiss"][data-theme="auto"] { + --paper: oklch(0.10 0 0); + --paper-2: oklch(0.16 0 0); + --ink: oklch(0.985 0 0); + --ink-2: oklch(0.92 0 0); + --muted: oklch(0.62 0 0); + --rule: oklch(0.985 0 0); + } +} +:root[data-template="swiss"][data-theme="dark"] { + --paper: oklch(0.10 0 0); + --paper-2: oklch(0.16 0 0); + --ink: oklch(0.985 0 0); + --ink-2: oklch(0.92 0 0); + --muted: oklch(0.62 0 0); + --rule: oklch(0.985 0 0); +} + +/* clean off the editorial paper grain */ +:root[data-template="swiss"] body::before { display: none; } + +/* ───── marker bar ───── */ +:root[data-template="swiss"] .marker { + border-bottom: 2px solid var(--ink); + font-weight: 700; + letter-spacing: 0.06em; + color: var(--ink); +} +:root[data-template="swiss"] .marker__brand { color: var(--ink); gap: 0.55rem; } +:root[data-template="swiss"] .marker__brand .star { + width: 12px; height: 12px; + background: var(--accent); + border-radius: 50%; + display: inline-block; + font-size: 0; transform: none; +} +:root[data-template="swiss"] .marker__year { color: var(--accent); } +:root[data-template="swiss"] .theme { + border-radius: 0; + border-color: var(--ink); + font-family: var(--display); + font-weight: 700; + text-transform: uppercase; + color: var(--ink); + background: transparent; + padding: 6px 14px; +} +:root[data-template="swiss"] .theme:hover { background: var(--ink); color: var(--paper); } + +/* ───── hero — true poster ───── */ +:root[data-template="swiss"] .hero__name { + font-family: var(--display); + font-style: normal; + font-weight: 900; + font-variation-settings: normal; + text-transform: uppercase; + letter-spacing: -0.045em; + line-height: 0.84; +} +:root[data-template="swiss"] .hero__name em { + font-style: normal; + font-weight: 900; + font-variation-settings: normal; + color: var(--accent); +} +/* Replace typographic asterisk with primary geometric mark — solid red disc */ +:root[data-template="swiss"] .hero__asterism { + font-size: 0; color: transparent; + width: clamp(3rem, 7vw, 5.5rem); + height: clamp(3rem, 7vw, 5.5rem); + background: var(--accent); + border-radius: 50%; + transform: none; + animation: swiss-pop 900ms 200ms cubic-bezier(0.16, 1, 0.30, 1) both; +} +@keyframes swiss-pop { + from { opacity: 0; transform: scale(0.4); } + to { opacity: 1; transform: scale(1); } +} +:root[data-template="swiss"] .hero__tagline { + font-family: var(--display); + font-style: normal; + font-weight: 600; + font-variation-settings: normal; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--ink); + font-size: clamp(1.15rem, 0.85rem + 1.4vw, 1.85rem); + line-height: 1.1; + max-width: 32ch; +} +:root[data-template="swiss"] .hero__tagline span { color: var(--accent); padding: 0 0.35em; } +:root[data-template="swiss"] .hero__bio { + color: var(--ink); + font-weight: 400; + font-family: var(--body); +} + +/* ───── social ───── */ +:root[data-template="swiss"] .social a { + border-radius: 0; + border-color: var(--ink); + border-width: 1.5px; + font-family: var(--display); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: var(--fs-mini); + padding: 9px 14px; + color: var(--ink); +} +:root[data-template="swiss"] .social a:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + transform: none; +} + +/* ───── sections ───── */ +:root[data-template="swiss"] .section__head { + border-bottom: 4px solid var(--ink); + padding-bottom: 0.85rem; +} +:root[data-template="swiss"] .section__numwrap small { + color: var(--ink); + font-weight: 700; + font-family: var(--display); +} +:root[data-template="swiss"] .section__num { + font-family: var(--display); + font-style: normal; + font-weight: 900; + font-variation-settings: normal; + color: var(--accent); + letter-spacing: -0.05em; +} +:root[data-template="swiss"] .section__title { + font-family: var(--display); + font-style: normal; + font-weight: 900; + font-variation-settings: normal; + text-transform: uppercase; + letter-spacing: -0.025em; + color: var(--ink); +} +:root[data-template="swiss"] .section__kicker { + font-family: var(--display); + font-style: normal; + font-weight: 500; + font-variation-settings: normal; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: var(--fs-mini); + color: var(--muted); +} + +/* ───── cards ───── */ +:root[data-template="swiss"] .card { + border-radius: 0; + border-color: var(--ink); + background: var(--paper); + transition: background 200ms cubic-bezier(0.22, 1, 0.36, 1), color 200ms cubic-bezier(0.22, 1, 0.36, 1); + isolation: isolate; +} +:root[data-template="swiss"] .card:hover { + transform: none; + box-shadow: none; + background: var(--ink); + color: var(--paper); + border-color: var(--ink); +} +:root[data-template="swiss"] .card:hover .card__title, +:root[data-template="swiss"] .card:hover .card__desc, +:root[data-template="swiss"] .card:hover .card__host { color: var(--paper); } +:root[data-template="swiss"] .card::after { + font-family: var(--display); + font-weight: 700; + color: var(--ink); +} +:root[data-template="swiss"] .card:hover::after { color: var(--accent); transform: translate(3px, -3px); } + +:root[data-template="swiss"] .card__title { + font-family: var(--display); + font-weight: 700; + text-transform: uppercase; + letter-spacing: -0.005em; +} +:root[data-template="swiss"] .card__desc { + font-family: var(--body); + color: var(--muted); +} +:root[data-template="swiss"] .card__host { + font-family: var(--display); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.08em; +} + +/* link card favicon */ +:root[data-template="swiss"] .card--link .favicon { + border-radius: 0; + border-color: var(--ink); + background: var(--paper); +} +:root[data-template="swiss"] .card--link:hover .favicon { + background: var(--paper); + border-color: var(--paper); +} +:root[data-template="swiss"] .card--link .favicon[data-fallback] { + font-family: var(--display); + font-weight: 900; + color: var(--ink); +} + +/* project card */ +:root[data-template="swiss"] .card--project .card__title { + font-family: var(--display); + font-weight: 900; + font-style: normal; + font-variation-settings: normal; + text-transform: uppercase; + font-size: var(--fs-lg); + line-height: 0.95; +} + +/* featured — solid red, white text, true poster */ +:root[data-template="swiss"] .card--featured { + background: var(--accent); + color: var(--on-accent); + border-color: var(--ink); +} +:root[data-template="swiss"] .card--featured:hover { + background: var(--ink); + color: var(--paper); +} +:root[data-template="swiss"] .card--featured::before { + font-family: var(--display); + font-weight: 700; + letter-spacing: 0.18em; +} +:root[data-template="swiss"] .card--featured .card__title { + color: var(--on-accent); + font-weight: 900; + text-transform: uppercase; + font-size: clamp(1.95rem, 1.30rem + 1.8vw, 2.8rem); +} +:root[data-template="swiss"] .card--featured:hover .card__title, +:root[data-template="swiss"] .card--featured:hover .card__desc { color: var(--paper); } +:root[data-template="swiss"] .card--featured .card__desc { + color: color-mix(in oklch, var(--on-accent) 85%, transparent); +} + +/* tags — square, monoline, all caps */ +:root[data-template="swiss"] .tag { + border-radius: 0; + border-color: var(--ink); + background: var(--paper); + font-family: var(--display); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink); +} +:root[data-template="swiss"] .card:hover .tag { + background: var(--paper); + color: var(--ink); + border-color: var(--paper); +} +:root[data-template="swiss"] .card--featured .tag { + background: color-mix(in oklch, var(--on-accent) 14%, transparent); + border-color: color-mix(in oklch, var(--on-accent) 30%, transparent); + color: var(--on-accent); +} +:root[data-template="swiss"] .card--featured:hover .tag { + background: color-mix(in oklch, var(--paper) 14%, transparent); + border-color: color-mix(in oklch, var(--paper) 30%, transparent); + color: var(--paper); +} + +/* youtube */ +:root[data-template="swiss"] .card--youtube { + border-radius: 0; + border-color: var(--ink); +} +:root[data-template="swiss"] .yt__play { + border-radius: 0; + border-width: 2px; + background: rgba(0,0,0,0.7); +} +:root[data-template="swiss"] .yt:hover .yt__play { + background: var(--accent); + border-color: var(--accent); + transform: translate(-50%, -50%) scale(1.05); +} +:root[data-template="swiss"] .yt__title { + font-family: var(--display); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* portfolio */ +:root[data-template="swiss"] .card--portfolio { + border-radius: 0; + border-color: var(--ink); +} +:root[data-template="swiss"] .card--portfolio:hover { background: var(--paper); color: var(--ink); } +:root[data-template="swiss"] .card--portfolio:hover .portfolio__caption .card__title { color: var(--accent); } +:root[data-template="swiss"] .portfolio__caption { + border-top: 2px solid var(--ink); + padding: 1.1rem 1.35rem 1.2rem; +} +:root[data-template="swiss"] .portfolio__caption .card__title { + font-family: var(--display); + font-weight: 900; + font-variation-settings: normal; + text-transform: uppercase; + letter-spacing: -0.02em; + font-size: var(--fs-lg); + line-height: 1; +} +:root[data-template="swiss"] .portfolio__caption .card__desc { color: var(--muted); } + +/* clients */ +:root[data-template="swiss"] .client__logo { + border-radius: 0; + border-color: var(--ink); + background: var(--paper); +} +/* opt the client tile out of the global card hover-flip: the card has no + chrome, so filling it with ink would hide the title text below the logo */ +:root[data-template="swiss"] .card--client:hover { + background: transparent; + color: var(--ink); + border-color: transparent; +} +:root[data-template="swiss"] .card--client:hover .client__title { color: var(--accent); } +:root[data-template="swiss"] .card--client:hover .client__logo { + background: var(--ink); + border-color: var(--ink); + transform: none; + box-shadow: none; +} +:root[data-template="swiss"] .card--client:hover .client__logo img.is-favicon { filter: invert(1); } +:root[data-template="swiss"] .client__logo[data-fallback] { + font-family: var(--display); + font-weight: 900; + color: var(--ink); +} +:root[data-template="swiss"] .card--client:hover .client__logo[data-fallback] { color: var(--paper); } +:root[data-template="swiss"] .client__title { + font-family: var(--display); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: var(--fs-mini); + color: var(--ink); +} + +/* footer */ +:root[data-template="swiss"] .foot { + border-top: 2px solid var(--ink); + color: var(--ink); + font-family: var(--display); + font-weight: 600; +} +:root[data-template="swiss"] .foot__mark { + font-family: var(--display); + font-style: normal; + text-transform: uppercase; + font-weight: 900; + color: var(--accent); + font-size: var(--fs-md); + letter-spacing: 0.08em; +} + +/* selection */ +:root[data-template="swiss"] ::selection { + background: var(--accent); + color: var(--on-accent); +} + +/* override editorial entrance for the asterism (it spins; Swiss should pop) */ +:root[data-template="swiss"] .hero__asterism { animation: swiss-pop 900ms 200ms cubic-bezier(0.16, 1, 0.30, 1) both; } diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..d671faa --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,303 @@ +/* dlstack — distilled + * Loads data/links.json and renders sections, cards, YouTube facades. + * No search, no filter, no clock, no theme toggle. The page is the index. + */ +(() => { + "use strict"; + + const $ = (s, el = document) => el.querySelector(s); + const $$ = (s, el = document) => Array.from(el.querySelectorAll(s)); + const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + }[c])); + const frag = (html) => document.createRange().createContextualFragment(html); + + const hostOf = (url) => { + try { return new URL(url, location.href).hostname.replace(/^www\./, ""); } + catch { return ""; } + }; + const faviconFor = (url) => { + const h = hostOf(url); + return h ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(h)}&sz=128` : ""; + }; + const ytThumb = (id) => `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`; + + function renderLink(it) { + const host = hostOf(it.url); + const custom = it.image || it.icon; + const src = custom || faviconFor(it.url); + const isFavicon = !custom; + const initial = (it.title || host || "·").trim().charAt(0).toUpperCase(); + return ` + + + ${src + ? `` + : esc(initial)} + + + ${esc(it.title)} + ${it.description ? `${esc(it.description)}` : ""} + ${host ? `${esc(host)}` : ""} + + `; + } + + function renderProject(it) { + const featured = it.featured ? " card--featured" : ""; + const tag = it.url ? "a" : "div"; + const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : ""; + const tags = (it.tags || []).map(t => `${esc(t)}`).join(""); + return ` + <${tag} class="card card--project${featured} reveal" ${attrs}> + ${esc(it.title)} + ${it.description ? `

${esc(it.description)}

` : ""} + ${tags ? `
${tags}
` : ""} + `; + } + + function renderYouTube(it) { + return ` +
+
+ +
${esc(it.title)}
+
+
`; + } + + function renderPortfolio(it) { + const src = it.image || it.url; + const tag = it.url ? "a" : "div"; + const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : ""; + const ratio = it.ratio ? ` style="--portfolio-ratio:${esc(String(it.ratio).replace(":", " / "))}"` : ""; + return ` + <${tag} class="card card--portfolio reveal" ${attrs}> +
+ ${src ? `${esc(it.title || ` : ""} +
+ ${(it.title || it.description) ? ` +
+ ${it.title ? `${esc(it.title)}` : ""} + ${it.description ? `${esc(it.description)}` : ""} +
` : ""} + `; + } + + function renderClient(it) { + const host = hostOf(it.url); + const custom = it.image || it.icon; + const src = custom || faviconFor(it.url); + const isFavicon = !custom; + const initial = (it.title || host || "·").trim().charAt(0).toUpperCase(); + const tag = it.url ? "a" : "div"; + const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : ""; + return ` + <${tag} class="card card--client reveal" ${attrs} title="${esc(it.title || host || "")}"> + + ${it.title ? `${esc(it.title)}` : ""} + `; + } + + const renderItem = (it) => + it.type === "youtube" ? renderYouTube(it) : + it.type === "card" ? renderProject(it) : + it.type === "client" ? renderClient(it) : + it.type === "portfolio" ? renderPortfolio(it) : + renderLink(it); + + function renderSection(sec, n) { + const items = (sec.items || []).map(renderItem).join(""); + const num = String(n).padStart(2, "0"); + const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client"; + const gridClass = isClients ? "grid--clients" : "grid"; + return ` +
+
+
+ + ${num} +
+
+

${esc(sec.label)}

+ ${sec.kicker ? `${esc(sec.kicker)}` : ""} +
+
+
${items}
+
`; + } + + const SOCIAL_ICONS = { + github: '', + linkedin: '', + mail: '', + rss: '', + link: '', + calendar: '', + bluesky: 'Bluesky' + }; + + function renderSocial(items) { + return items.map(s => + `${SOCIAL_ICONS[s.icon] || SOCIAL_ICONS.link}${esc(s.label)}` + ).join(""); + } + + function nameMarkup(name) { + if (!name) return ""; + const parts = String(name).trim().split(/\s+/); + if (parts.length < 2) return esc(name); + const last = parts.pop(); + return `${esc(parts.join(" "))} ${esc(last)}`; + } + + function attachFaviconFallback(root) { + $$("img[data-fallback-initial]", root).forEach(img => { + img.addEventListener("error", () => { + const initial = img.dataset.fallbackInitial || "·"; + const parent = img.parentElement; + if (parent) { parent.setAttribute("data-fallback", ""); parent.textContent = initial; } + }, { once: true }); + }); + } + + function attachYouTube(root) { + const open = (fac) => { + const iframe = document.createElement("iframe"); + iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(fac.dataset.yt)}?autoplay=1&rel=0`; + iframe.allow = "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"; + iframe.allowFullscreen = true; + iframe.title = fac.getAttribute("aria-label") || "YouTube video"; + fac.replaceChildren(iframe); + fac.style.cursor = "default"; + }; + root.addEventListener("click", (e) => { + const fac = e.target.closest("[data-yt]"); + if (fac) { e.preventDefault(); open(fac); } + }); + root.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + const fac = e.target.closest("[data-yt]"); + if (fac) { e.preventDefault(); open(fac); } + }); + } + + function attachTheme() { + const root = document.documentElement; + const btn = $("#theme"); + if (!btn) return; + const order = ["auto", "light", "dark"]; + const label = { auto: "Auto", light: "Light", dark: "Dark" }; + const stored = localStorage.getItem("dlstack-theme"); + if (!order.includes(root.dataset.theme)) root.dataset.theme = "auto"; + if (order.includes(stored)) root.dataset.theme = stored; + const sync = () => { + btn.textContent = label[root.dataset.theme]; + btn.setAttribute("aria-label", `Theme: ${label[root.dataset.theme]}. Click to change.`); + }; + sync(); + btn.addEventListener("click", () => { + const i = order.indexOf(root.dataset.theme); + root.dataset.theme = order[(i + 1) % order.length]; + localStorage.setItem("dlstack-theme", root.dataset.theme); + sync(); + }); + } + + function attachReveal(root) { + if (!("IntersectionObserver" in window)) { + $$(".reveal", root).forEach(el => el.classList.add("in")); + return; + } + const io = new IntersectionObserver((entries) => { + entries.forEach(en => { + if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); } + }); + }, { threshold: 0.08, rootMargin: "0px 0px -40px 0px" }); + $$(".reveal", root).forEach(el => io.observe(el)); + } + + async function loadData() { + const sources = ["data/links.json", "data/links.example.json"]; + let lastErr; + for (const src of sources) { + try { + const res = await fetch(src, { cache: "no-cache" }); + if (!res.ok) { lastErr = new Error(`${src}: HTTP ${res.status}`); continue; } + return await res.json(); + } catch (err) { lastErr = err; } + } + throw lastErr || new Error("No data file found."); + } + + async function main() { + const app = $("#app"); + let data; + try { + data = await loadData(); + } catch (err) { + const p = document.createElement("p"); + p.style.cssText = "color:var(--accent);font-family:var(--mono);padding:2rem;max-width:48ch"; + p.textContent = `Couldn't load data/links.json or data/links.example.json. Check that one exists and is valid JSON. Details: ${err.message}`; + app.replaceChildren(p); + return; + } + + if (data.theme?.accent) { + document.documentElement.style.setProperty("--accent", data.theme.accent); + } + const tpl = data.theme?.template === "swiss" ? "swiss" : "editorial"; + document.documentElement.dataset.template = tpl; + try { localStorage.setItem("dlstack-template", tpl); } catch (e) {} + + const p = data.profile || {}; + const sections = data.sections || []; + const social = data.social || []; + document.title = `${p.name || "Links"}`; + + const taglineMarkup = (t) => + esc(t).replace(/\s*[·•|]\s*/g, ''); + + const html = ` +
+ Index №01 + MMXXVI + +
+ +
+ +

${nameMarkup(p.name)}

+ ${p.tagline ? `

${taglineMarkup(p.tagline)}

` : ""} + ${p.bio ? `

${esc(p.bio)}

` : ""} + ${social.length ? `` : ""} +
+ +
${sections.map((s, i) => renderSection(s, i + 1)).join("")}
+ +
+ ${esc(data.footer?.copy || "")} + + © ${new Date().getFullYear()} ${esc(p.name || "")} +
+ `; + app.replaceChildren(frag(html)); + + attachFaviconFallback(app); + attachYouTube(app); + attachReveal(app); + attachTheme(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", main); + } else { + main(); + } +})(); diff --git a/data/links.example.json b/data/links.example.json new file mode 100644 index 0000000..8f97e4a --- /dev/null +++ b/data/links.example.json @@ -0,0 +1,102 @@ +{ + "profile": { + "name": "Ada Lovelace", + "handle": "@ada", + "tagline": "Mathematician · Programmer · Visionary", + "bio": "Short, one-line introduction sits here. Replace everything in this file with your own content, save as data/links.json, and re-upload.", + "location": "London" + }, + "theme": { + "accent": "#E8482C", + "template": "editorial" + }, + "sections": [ + { + "id": "sites", + "label": "Sites", + "kicker": "Where I live online", + "items": [ + { "type": "link", "title": "Personal site", "url": "https://example.com", "description": "What I do, who I am" }, + { "type": "link", "title": "Notes", "url": "https://notes.example.com", "description": "Long-form writing" }, + { "type": "link", "title": "Lab", "url": "https://lab.example.com", "description": "Experiments & sketches" } + ] + }, + { + "id": "projects", + "label": "Projects", + "kicker": "Things I'm building", + "items": [ + { + "type": "card", + "featured": true, + "title": "Analytical Engine", + "url": "https://example.com/projects/engine", + "description": "An early proposal for a general-purpose computing machine, with looping and conditional branching.", + "tags": ["computing", "research"] + }, + { + "type": "card", + "title": "Bernoulli Numbers", + "url": "https://example.com/projects/bernoulli", + "description": "Method for computing Bernoulli numbers using the Engine.", + "tags": ["mathematics"] + } + ] + }, + { + "id": "showcase", + "label": "Showcase", + "kicker": "Selected work", + "items": [ + { + "type": "portfolio", + "title": "Notes G — diagrams for publication", + "url": "https://example.com/showcase/notes-g", + "image": "https://images.unsplash.com/photo-1518770660439-4636190af475?w=1600&q=80", + "description": "Tables and diagrams prepared for the 1843 translation." + } + ] + }, + { + "id": "watch", + "label": "Watch", + "kicker": "Video", + "items": [ + { "type": "youtube", "id": "dQw4w9WgXcQ", "title": "Replace this video ID with your own" } + ] + }, + { + "id": "clients", + "label": "Clients", + "kicker": "Who I work with", + "layout": "clients", + "items": [ + { "type": "client", "title": "Example Co.", "url": "https://example.com" }, + { "type": "client", "title": "Another Org", "url": "https://another.example.com" }, + { "type": "client", "title": "Third Party", "url": "https://third.example.com" }, + { "type": "client", "title": "Fourth Group", "url": "https://fourth.example.com" }, + { "type": "client", "title": "Fifth Cooperative","url": "https://fifth.example.coop" }, + { "type": "client", "title": "Sixth Studio", "url": "https://sixth.example.com" } + ] + }, + { + "id": "writing", + "label": "Writing", + "kicker": "Notes & long-form", + "items": [ + { "type": "link", "title": "An essay title goes here", "url": "#", "description": "Essay · 8 min read" }, + { "type": "link", "title": "Another piece of writing", "url": "#", "description": "Field notes" } + ] + } + ], + "social": [ + { "label": "GitHub", "url": "https://github.com/example", "icon": "github" }, + { "label": "LinkedIn", "url": "https://www.linkedin.com/in/example", "icon": "linkedin" }, + { "label": "Email", "url": "mailto:hello@example.com", "icon": "mail" }, + { "label": "RSS", "url": "/feed.xml", "icon": "rss" } + ], + "footer": { + "copy": "Hand-built. No trackers.", + "year": "auto" + } +} diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..a550448 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..4dda428 --- /dev/null +++ b/index.html @@ -0,0 +1,54 @@ + + + + + + + + + Links — index + + + + + + + + + + + + + + + + + + + + + + + + + Skip to content +
+ +
+ + +