Initial commit: dlstack file-driven link index

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.
This commit is contained in:
Joel Brock
2026-05-15 13:37:09 -07:00
commit 36084013c8
8 changed files with 1767 additions and 0 deletions

20
.gitignore vendored Normal file
View File

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

377
README.md Normal file
View File

@@ -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 <https://www.joelbrock.org>.
## 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(108124px, 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) | <https://simpleicons.org> |
| **Lucide** | Generic UI icons — mail, rss, link, phone, calendar, etc. (1500+, MIT-licensed) | <https://lucide.dev/icons> |
| **Phosphor** | 9000+ icons with multiple weights (regular / bold / thin / duotone) | <https://phosphoricons.com> |
| **Heroicons** | Tailwind's set, clean and minimal | <https://heroicons.com> |
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: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>',
linkedin: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>',
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">…</svg>',
rss: '<svg viewBox="0 0 24 24" fill="currentColor">…</svg>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">…</svg>',
// Newly added — Bluesky from Simple Icons:
bluesky: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="…"/></svg>'
};
```
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, 24px 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 `<head>`
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.

499
assets/css/styles.css Normal file
View File

@@ -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,<svg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
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; }

407
assets/css/swiss.css Normal file
View File

@@ -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; }

303
assets/js/app.js Normal file
View File

@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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 `
<a class="card card--link reveal" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">
<span class="favicon" ${src ? "" : "data-fallback"}>
${src
? `<img loading="lazy" alt="" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
: esc(initial)}
</span>
<span>
<span class="card__title">${esc(it.title)}</span>
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
${host ? `<span class="card__host">${esc(host)}</span>` : ""}
</span>
</a>`;
}
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 => `<span class="tag">${esc(t)}</span>`).join("");
return `
<${tag} class="card card--project${featured} reveal" ${attrs}>
<span class="card__title">${esc(it.title)}</span>
${it.description ? `<p class="card__desc">${esc(it.description)}</p>` : ""}
${tags ? `<div class="tags">${tags}</div>` : ""}
</${tag}>`;
}
function renderYouTube(it) {
return `
<div class="card card--youtube reveal">
<div class="yt" role="button" tabindex="0" aria-label="Play: ${esc(it.title)}" data-yt="${esc(it.id)}" style="background-image:url('${esc(ytThumb(it.id))}')">
<div class="yt__play" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
</div>
<div class="yt__title">${esc(it.title)}</div>
</div>
</div>`;
}
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}>
<div class="portfolio__media"${ratio}>
${src ? `<img loading="lazy" alt="${esc(it.title || "")}" src="${esc(src)}">` : ""}
</div>
${(it.title || it.description) ? `
<div class="portfolio__caption">
${it.title ? `<span class="card__title">${esc(it.title)}</span>` : ""}
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
</div>` : ""}
</${tag}>`;
}
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 || "")}">
<span class="client__logo" ${src ? "" : "data-fallback"}>
${src
? `<img loading="lazy" alt="${esc(it.title || host || "")}" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
: esc(initial)}
</span>
${it.title ? `<span class="client__title">${esc(it.title)}</span>` : ""}
</${tag}>`;
}
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 `
<section class="section">
<header class="section__head">
<div class="section__numwrap">
<small>№</small>
<span class="section__num">${num}</span>
</div>
<div class="section__titlewrap">
<h2 class="section__title">${esc(sec.label)}</h2>
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
</div>
</header>
<div class="${gridClass}">${items}</div>
</section>`;
}
const SOCIAL_ICONS = {
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.87-1.37-3.87-1.37-.52-1.34-1.28-1.7-1.28-1.7-1.05-.71.08-.7.08-.7 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.3 1.19-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11.05 11.05 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.74.8 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.25 5.69.41.36.78 1.06.78 2.14v3.17c0 .31.2.67.8.56A11.5 11.5 0 0 0 12 .5Z"/></svg>',
linkedin: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.98 3.5A2.5 2.5 0 1 1 4.97 8.5 2.5 2.5 0 0 1 4.98 3.5ZM3 9.75h4V21H3V9.75ZM9.5 9.75h3.8v1.55h.05c.53-1 1.83-2.05 3.77-2.05 4.03 0 4.78 2.65 4.78 6.1V21H18V16.1c0-1.17-.02-2.68-1.63-2.68-1.63 0-1.88 1.27-1.88 2.6V21H9.5V9.75Z"/></svg>',
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
rss: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 7a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3Zm1.5 6a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"/></svg>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
calendar: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days-icon lucide-calendar-days"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>',
bluesky: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bluesky</title><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>'
};
function renderSocial(items) {
return items.map(s =>
`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${SOCIAL_ICONS[s.icon] || SOCIAL_ICONS.link}<span>${esc(s.label)}</span></a>`
).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(" "))} <em>${esc(last)}</em>`;
}
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, '<span aria-hidden="true">·</span>');
const html = `
<div class="marker">
<span class="marker__brand"><span class="star" aria-hidden="true">✱</span> Index №01</span>
<span class="marker__year">MMXXVI</span>
<button id="theme" class="theme" type="button">Auto</button>
</div>
<header class="hero">
<span class="hero__asterism" aria-hidden="true">✱</span>
<h1 class="hero__name">${nameMarkup(p.name)}</h1>
${p.tagline ? `<p class="hero__tagline">${taglineMarkup(p.tagline)}</p>` : ""}
${p.bio ? `<p class="hero__bio">${esc(p.bio)}</p>` : ""}
${social.length ? `<nav class="social" aria-label="Social">${renderSocial(social)}</nav>` : ""}
</header>
<main>${sections.map((s, i) => renderSection(s, i + 1)).join("")}</main>
<footer class="foot">
<span>${esc(data.footer?.copy || "")}</span>
<span class="foot__mark" aria-hidden="true">— ✱ —</span>
<span class="foot__right">© ${new Date().getFullYear()} ${esc(p.name || "")}</span>
</footer>
`;
app.replaceChildren(frag(html));
attachFaviconFallback(app);
attachYouTube(app);
attachReveal(app);
attachTheme();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
})();

102
data/links.example.json Normal file
View File

@@ -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"
}
}

5
favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#F8F2E8"/>
<circle cx="16" cy="16" r="6" fill="#E8482C"/>
<circle cx="16" cy="16" r="11" fill="none" stroke="#1A1A2E" stroke-width="1.2" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

54
index.html Normal file
View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en" data-theme="auto" data-template="editorial">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="description" content="Joel Brock — consulting technologist in Portland, OR. Links to my work in cooperative technology, projects, and video.">
<title>Links — index</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="alternate icon" type="image/png" href="data:image/png;base64,">
<!-- Preconnect to font + favicon services for snappy first paint -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://www.google.com">
<link rel="preconnect" href="https://i.ytimg.com">
<!-- Editorial: Fraunces (variable serif) + Geist (refined sans) + Geist Mono
Swiss: Archivo (variable grotesque, near-Akzidenz/Univers) -->
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..600,30..100,0..1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
// Apply saved theme + template before paint to avoid flash
try {
var t = localStorage.getItem("dlstack-theme");
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
var tpl = localStorage.getItem("dlstack-template");
if (tpl === "swiss" || tpl === "editorial") document.documentElement.dataset.template = tpl;
} catch (e) {}
</script>
<link rel="stylesheet" href="assets/css/styles.css">
<link rel="stylesheet" href="assets/css/swiss.css">
<meta property="og:type" content="website">
<meta property="og:title" content="Joel Brock — links">
<meta property="og:description" content="Consulting technologist in Portland, OR. Cooperative technology, projects, and video.">
<meta name="twitter:card" content="summary">
</head>
<body>
<a class="sr-only" href="#app">Skip to content</a>
<div class="shell" id="app">
<noscript>
<p style="color:var(--accent);font-family:var(--mono);padding:2rem;max-width:48ch">
JavaScript is required to render this page — it reads the link list from <code>data/links.json</code>.
Enable JavaScript and reload, or browse <a href="data/links.json">the raw data file</a>.
</p>
</noscript>
</div>
<script src="assets/js/app.js" defer></script>
</body>
</html>