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

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.