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

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();
}
})();