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.
378 lines
14 KiB
Markdown
378 lines
14 KiB
Markdown
# 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(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) | <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, 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 `<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.
|