Three sites, one directory each

flux.vision, tobias.raayoni-last.com and randogoth.com. They were one site until
October 2026, serving four audiences from a single dated list, which is the only
format that can hold unrelated things at once.

flux.vision is recovered from the Wayback Machine, where it had sat since being
deleted in 2021: 90 articles, closed and maintained rather than updated.
tobias.raayoni-last.com is the professional record, 97 articles back to 1999.
randogoth.com is the technical work, 41 articles across six areas, served as an
ANSI bulletin board.

History starts here deliberately. The development history, the 266-row inventory
that records why each article went where it did, the Wayback pipelines, the
pruned articles, the source archives and the full-size images are kept beside
the repo rather than in it, so a clone is the web presence and not the argument
that produced it. REFACTOR.md is gitignored for the same reason. See README.md
for where that material lives, and keep it: the sort is not reproducible from
these three directories alone.

Each site documents itself in its own README, including the traps that cost time
and look like nothing when you get them wrong.
This commit is contained in:
randogoth 2026-10-05 20:54:17 +03:00
commit 8505b7e9e0
1003 changed files with 13289 additions and 0 deletions

View file

@ -0,0 +1,338 @@
/* raayoni — tobias.raayoni-last.com
*
* Index after dominofactory.net: a Muuri grid of greyscale-at-rest tiles.
* Entry after fabriziopoltronieri.com: fixed parallax hero, narrow centred
* column, and a hierarchy that switches typeface by purpose — sans small-caps
* for titles, mono for metadata, serif for prose.
*
* Both references hard-code hex and are dark-only. Everything here goes through
* custom properties so the light/dark switch is not a retrofit.
*/
/* ── Lato, self-hosted ───────────────────────────────────────────────────
* SIL Open Font License, so redistribution is fine. Self-hosted rather than
* loaded from Google's CDN: no third-party request on a site aimed partly at
* German and EU cultural institutions, and no external dependency in a static
* build. latin-ext is needed for the client names — Kežmarok, szerk, Schmück.
*
* Lato ships 400, 700 and 900 only. There is no 800 or 600, so the weights
* below are snapped to what actually exists rather than letting the browser
* synthesise.
*/
@font-face { font-family: Lato; font-style: normal; font-weight: 400; font-display: swap;
src: url(../fonts/lato-400-normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Lato; font-style: normal; font-weight: 400; font-display: swap;
src: url(../fonts/lato-400-normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
@font-face { font-family: Lato; font-style: italic; font-weight: 400; font-display: swap;
src: url(../fonts/lato-400-italic-latin.woff2) format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Lato; font-style: italic; font-weight: 400; font-display: swap;
src: url(../fonts/lato-400-italic-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
@font-face { font-family: Lato; font-style: normal; font-weight: 700; font-display: swap;
src: url(../fonts/lato-700-normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Lato; font-style: normal; font-weight: 700; font-display: swap;
src: url(../fonts/lato-700-normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
@font-face { font-family: Lato; font-style: normal; font-weight: 900; font-display: swap;
src: url(../fonts/lato-900-normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Lato; font-style: normal; font-weight: 900; font-display: swap;
src: url(../fonts/lato-900-normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
/* ── palette ─────────────────────────────────────────────────────────────
* Contrast note: the entry reference sets metadata at #444 and subheads at
* #555 on a #111 ground — about 1.9:1 and 2.6:1, which fails WCAG AA badly.
* The recessive hierarchy is kept, but the quiet tones are lifted to pass.
*/
:root {
--font-sans: Lato, "Helvetica Neue", Arial, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-serif: "Source Serif 4", Charter, Georgia, "Times New Roman", serif;
--measure: 60%; /* entry column width, per the reference */
--accent: #ffa07a; /* lightsalmon, from the reference */
}
:root, [data-theme="dark"] {
--ground: #111;
--ground-alt: #191919;
--title: #aaa;
--body: #bbb;
--sub: #777; /* lifted from #555 */
--meta: #8d8d8d; /* lifted from #444 */
--rule: #2a2a2a;
--tile: #1a1a1a;
--panel-lift: rgba(0,0,0,.6);
--thumb-rest: grayscale(100%) contrast(1.05);
}
[data-theme="light"] {
--ground: #f7f6f4;
--ground-alt: #efeeea;
--title: #2b2b2b;
--body: #343434;
--sub: #6a6a6a;
--meta: #5f5f5f;
--rule: #dcdad4;
--tile: #eceae5;
--panel-lift: rgba(0,0,0,.22);
/* grayscale alone reads washed out on a pale ground; a little contrast and
a touch of darkening keeps the tile deliberate rather than faded */
--thumb-rest: grayscale(100%) contrast(1.12) brightness(.94);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--ground: #f7f6f4; --ground-alt: #efeeea; --title: #2b2b2b; --body: #343434;
--sub: #6a6a6a; --meta: #5f5f5f; --rule: #dcdad4; --tile: #eceae5;
--panel-lift: rgba(0,0,0,.22);
--thumb-rest: grayscale(100%) contrast(1.12) brightness(.94);
}
}
/* ── base ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--ground);
color: var(--body);
font-family: var(--font-serif);
font-size: 1.3em;
line-height: 1.6;
letter-spacing: .0611111rem;
-webkit-font-smoothing: antialiased;
transition: background-color .35s ease, color .35s ease;
}
a { color: var(--body); text-decoration: none; border-bottom: 1px solid var(--rule); }
a:hover { color: var(--accent); border-bottom-color: var(--accent); }
h1, h2, h3 { font-family: var(--font-sans); font-weight: 900; text-align: left; margin: 0; }
/* Lato has no true small caps, so this is browser-synthesised. At weight 900
and display size it holds; for real control swap to:
text-transform: uppercase; font-size: 3em; letter-spacing: .02em; */
h1 { font-size: 4em; color: var(--title); font-variant-caps: all-small-caps;
padding: 3em 0 0; line-height: 1; }
h1.tight { padding-top: .35em; }
h2 { font-size: 2.2em; color: var(--title); }
h3 { font-size: 1.8em; color: var(--sub); padding: 0 15vw 2vh 0; }
h4 { font-family: var(--font-mono); font-weight: 400; font-size: 1.2em; color: var(--meta); margin: 0; }
p { margin: 0; padding: 0 0 3vh; }
/* ── chrome ───────────────────────────────────────────────────────────── */
.bar {
position: sticky; top: 0; z-index: 20;
display: flex; gap: 1.2rem; align-items: baseline; flex-wrap: wrap;
padding: .9rem 5vw; background: color-mix(in srgb, var(--ground) 88%, transparent);
backdrop-filter: blur(8px); border-bottom: 1px solid var(--rule);
font-family: var(--font-mono); font-size: .72rem; letter-spacing: .04em;
}
.bar a, .bar button { border: 0; background: none; color: var(--meta); cursor: pointer;
font: inherit; padding: 0; text-transform: lowercase; }
.bar a:hover, .bar button:hover, .bar .on { color: var(--accent); }
.bar .me { color: var(--title); font-weight: 700; margin-right: auto; text-transform: none; }
.bar .sep { color: var(--rule); }
/* ── index grid ───────────────────────────────────────────────────────── */
.controls {
display: flex; gap: .55rem 1rem; flex-wrap: wrap; align-items: center;
padding: 2.2rem 5vw .4rem; font-family: var(--font-mono);
font-size: .72rem; letter-spacing: .04em;
}
.controls .label { color: var(--sub); margin-right: .2rem; }
.controls button { background: none; border: 1px solid var(--rule); border-radius: 2px;
color: var(--meta); cursor: pointer; font: inherit; padding: .22rem .55rem; }
.controls button:hover { color: var(--accent); border-color: var(--accent); }
.controls button[aria-pressed="true"] { color: var(--ground); background: var(--title); border-color: var(--title); }
.controls .count { color: var(--sub); margin-left: auto; }
.grid { position: relative; padding: 1rem 5vw 8vh; }
.item { display: block; position: absolute; width: 25%; padding: .55rem; z-index: 1; }
.item.muuri-item-hidden { z-index: 0; }
.item-content { position: relative; }
@media (max-width: 1500px) { .item { width: 33.333%; } }
@media (max-width: 1000px) { .item { width: 50%; } }
@media (max-width: 620px) { .item { width: 100%; } }
.hoverthumb { position: relative; overflow: hidden; background: var(--tile);
aspect-ratio: 4 / 3; border: 1px solid var(--rule); }
.hoverthumb img { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block; transition: transform 1.2s ease; }
/* Hovering a tile dims its siblings rather than colourising the target. Same
* job — one thing legible at a time across images of uneven quality — without
* needing a greyscale duplicate of every thumbnail. */
.grid:hover .item:not(.head) .item-content { opacity: .34; filter: saturate(.55); }
.grid .item:not(.head) .item-content:hover { opacity: 1; filter: none; }
.item-content { transition: opacity .32s ease, filter .32s ease; }
.item-content:hover .hoverthumb img { transform: scale(1.03); }
@media (hover: none) { .grid:hover .item .item-content { opacity: 1; filter: none; } }
/* No surviving image: the tile becomes typographic rather than broken.
~35 entries are in this position — see ../../IMAGES.md */
.hoverthumb.empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
.hoverthumb.empty span { font-family: var(--font-mono); font-size: .68rem;
color: var(--sub); letter-spacing: .12em; text-transform: uppercase; }
.item .content { padding: .7rem 0 0; }
.item .title { font-family: var(--font-sans); font-weight: 700; font-size: 1.05rem;
line-height: 1.12; color: var(--title); margin-bottom: .3rem; }
.item .year { font-family: var(--font-mono); font-size: .68rem; color: var(--sub); }
.item .description { font-size: .82rem; line-height: 1.35; color: var(--body);
margin: .3rem 0 .4rem; }
.item .tags { font-family: var(--font-mono); font-size: .66rem; color: var(--meta); }
.item .tags span { margin-right: .45rem; }
.item a.cover { position: absolute; inset: 0; border: 0; }
/* ── entry ────────────────────────────────────────────────────────────── */
/* The hero stops short of the fold and the prose panel rides up over it, so
* the page reads as having something below rather than ending at the image.
* A full-height hero gave no affordance to scroll at all.
*/
.hero { height: 82vh; background-attachment: fixed; background-position: 50%;
background-repeat: no-repeat; background-size: cover; position: relative; }
@media (max-width: 600px) { .hero { background-attachment: scroll; height: 56vh; } }
.pages { width: 100vw; display: flex; background: var(--ground);
position: relative; z-index: 2; }
/* The lift only applies when an image hero actually precedes the panel. Putting
it on .pages unconditionally pulled the title under the sticky bar on
timeline, disciplines and tags, which have no hero at all. */
.hero + .pages { margin-top: -5.5rem;
box-shadow: 0 -26px 46px -14px var(--panel-lift); }
@media (max-width: 600px) { .hero + .pages { margin-top: -3.5rem; } }
/* Listing pages start at the top of the document, so they need clearance from
the sticky bar in their own right. */
.pages > .frame > h1.tight:first-child { padding-top: 2.6rem; }
.hero + .pages > .frame > h1.tight:first-child,
.markplate + h1.tight { padding-top: .35em; }
/* a quiet cue on the hero itself */
.scrollcue { position: absolute; left: 50%; bottom: 6.6rem; transform: translateX(-50%);
z-index: 3; font-family: var(--font-mono); font-size: .62rem; letter-spacing: .22em;
text-transform: uppercase; color: var(--title); opacity: .75;
text-shadow: 0 1px 10px rgba(0,0,0,.65); pointer-events: none;
animation: cue 2.6s ease-in-out infinite; }
@keyframes cue { 0%,100% { transform: translate(-50%,0); } 50% { transform: translate(-50%,6px); } }
.frame { width: var(--measure); display: block; margin: auto; }
@media (max-width: 600px) { .frame { width: 100%; } }
.text { padding: 5vh 0 20vh; }
@media (max-width: 600px) {
h1 { font-size: 2.5em; padding: 15vw 5vw 0; }
h2 { font-size: 1.8em; padding-left: 5vw; }
h3 { font-size: 1.4em; padding: 2vh 5vw 0; }
h4 { padding: 2vw 5vw 0; }
p { font-size: 1em; padding: 2vh 5vw; }
.text { padding: 5vh 0; }
}
/* the credit line — For / HiredBy / With / PhotosBy, set in mono */
.credits { font-family: var(--font-mono); font-size: .78rem; line-height: 1.75;
color: var(--meta); padding: 0 0 3vh; }
.credits .rel { color: var(--sub); }
.credits a { border-bottom-color: var(--rule); }
@media (max-width: 600px) { .credits { padding: 0 5vw 3vh; } }
.entry-meta { font-family: var(--font-mono); font-size: .72rem; color: var(--sub);
letter-spacing: .06em; padding: .4rem 0 2vh; }
.entry-meta a { border: 0; color: var(--meta); }
.entry-meta a:hover { color: var(--accent); }
@media (max-width: 600px) { .entry-meta { padding-left: 5vw; } }
.body img { max-width: 100%; height: auto; display: block; margin: 0 0 3vh; }
.body iframe, .body embed { max-width: 100%; border: 0; margin: 0 0 3vh; }
.body h2 { font-size: 1.5em; padding: 2vh 0 1vh; }
/* ── listings ─────────────────────────────────────────────────────────── */
.rows { list-style: none; margin: 0; padding: 0 0 8vh; }
.rows li { display: flex; gap: 1.2rem; align-items: baseline; padding: .55rem 0;
border-bottom: 1px solid var(--rule); }
.rows .yr { font-family: var(--font-mono); font-size: .72rem; color: var(--sub);
min-width: 3.4rem; }
.rows .ti { font-family: var(--font-sans); font-weight: 700; font-size: 1rem;
color: var(--title); }
.rows .ct { font-family: var(--font-mono); font-size: .66rem; color: var(--meta);
margin-left: auto; }
.rows a { border: 0; }
.rows a:hover .ti { color: var(--accent); }
.lede { padding: 1vh 0 4vh; color: var(--sub); font-size: 1.05em; }
.cloud { padding: 0 0 8vh; font-family: var(--font-mono); font-size: .8rem; line-height: 2.2; }
.cloud a { margin-right: 1rem; }
footer { border-top: 1px solid var(--rule); padding: 3vh 5vw 6vh;
font-family: var(--font-mono); font-size: .7rem; color: var(--sub); }
footer a { border: 0; color: var(--meta); }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
.hero { background-attachment: scroll; }
}
/* ── the mark ─────────────────────────────────────────────────────────────
* The swift, as used on invoices. Inlined so fill="currentColor" resolves,
* which means it inverts with the theme from one asset: white on the dark
* ground, black on the light one.
*/
.bar .me { display: inline-flex; align-items: center; gap: .55rem; }
.mark { height: 1.45rem; width: auto; display: block; color: var(--title); }
.bar .me:hover .mark { color: var(--accent); }
/* Larger on the about page, where it is the identity rather than navigation. */
.markplate { display: block; margin: 0 0 2vh; }
.markplate .mark { height: 5.5rem; }
@media (max-width: 600px) { .markplate { padding-left: 5vw; } }
/* ── engagement grouping ──────────────────────────────────────────────────
* Entries belonging to a long relationship sit together under a full-width
* header. ClipAward is nine entries over four years spanning design,
* projection and animation — a flat grid cannot say that.
*/
.item.head { width: 100%; padding: 2.4rem .55rem .5rem; }
.item.head:first-child { padding-top: .6rem; }
.grouphead { border-bottom: 1px solid var(--rule); padding-bottom: .5rem;
display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
.gname { font-family: var(--font-sans); font-weight: 900; font-size: 1.45rem;
color: var(--title); font-variant-caps: all-small-caps; letter-spacing: .01em; }
.gmeta { font-family: var(--font-mono); font-size: .68rem; color: var(--sub);
letter-spacing: .04em; }
/* Flat arrangements hide the headers; so does any active filter. */
.grid:not([data-grouped]) .item.head { display: none; }
/* ── contact ──────────────────────────────────────────────────────────────
* The address is the point of this page, so it is set at display size rather
* than buried in a list. The old page hid it behind a document.write()
* obfuscation that Markdown rendered as visible source, so it was neither
* clickable nor hidden.
*/
.bigmail { font-family: var(--font-sans); font-weight: 700; font-size: 1.9em;
line-height: 1.2; padding: 1vh 0 3vh; word-break: break-word; }
.bigmail a { border-bottom-width: 2px; }
.profiles { list-style: none; margin: 0; padding: 2vh 0 3vh;
font-family: var(--font-mono); font-size: .82rem; }
.profiles li { display: flex; gap: .9rem; align-items: baseline; padding: .3rem 0; }
.profiles .rel { color: var(--sub); min-width: 6.2rem; }
.profiles a { border-bottom-color: var(--rule); }
@media (max-width: 600px) {
.bigmail, .profiles { padding-left: 5vw; padding-right: 5vw; }
.bigmail { font-size: 1.4em; }
}
/* no-JS fallback wording, kept quiet so it does not read as emphasis */
.bigmail i { font-style: normal; color: var(--sub); font-size: .62em;
font-family: var(--font-mono); padding: 0 .15em; }
/* Static pages start directly under the bar: no hero, so they need their own
top clearance rather than inheriting an empty 30vh placeholder. */
.pages > .frame > .markplate:first-child { padding-top: 3.2rem; }
.pages > .frame > .markplate:first-child + h1.tight { padding-top: .1em; }

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 161.36533 268.29031" role="img" aria-label="Tobias Raayoni Last"><g transform="translate(-150.26933,-390.24689)"><path fill="currentColor" transform="matrix(0.13333333,0,0,-0.13333333,0,816)" d="m 1127.08,2003.92 -0.06,-0.3 c 10.57,-3.95 107.03,-39.95 161.26,-55.57 53.65,-15.46 85.22,-23.27 89.12,-24.24 l 0.01,-0.25 c -7.26,-2.64 -111.38,-40.61 -161.15,-64.09 l -1.53,-0.72 c -12.12,-5.72 -6.89,-23.99 6.42,-22.46 75.33,8.66 191.92,21.61 236.48,24.16 67.75,3.87 248.11,-12.47 250.1,-12.65 l 0.03,-0.02 c 0.27,-0.96 11.45,-41.19 -6.82,-68.11 -18.49,-27.25 -180.05,-218.01 -216.07,-265.71 -36,-47.68 -71.04,-112.89 -98.29,-146.96 -27.25,-34.06 -95.38,-115.81 -124.58,-138.2 -29.2,-22.38 -60.34,-41.85 -53.53,-46.71 6.81,-4.87 76.89,6.81 131.39,22.38 54.51,15.58 241.37,109.98 327.99,175.19 86.62,65.2 189.79,154.09 247.21,212.81 57.42,58.73 120.68,135.62 156.7,184.28 35.97,48.62 38.92,62.26 39.89,91.41 l 0.02,0.08 c 1.65,0.34 126.77,26.55 165.44,69.09 l 37.44,41.2 c 0.99,1.08 2.16,1.97 3.47,2.62 l 19.22,9.6 0.02,0.27 -30.93,23.2 -0.02,0.01 c -0.19,0.22 -29.36,36.08 -78.84,59.36 -46.86,22.06 -62.49,25.89 -64.09,26.25 l -0.12,0.1 c 1.18,3.26 42.8,118.27 42.8,158.58 0,40.87 -44.77,157.66 -81.75,229.68 -36.99,72.02 -114.85,222.88 -140.15,264.73 -25.3,41.85 -115.82,175.19 -185.89,241.37 -70.08,66.18 -199.52,190.76 -238.46,198.54 -38.92,7.79 70.08,-134.3 70.08,-162.53 0,-28.23 35.04,-79.81 36.99,-109 1.95,-29.2 26.27,-90.52 30.17,-114.85 3.89,-24.33 41.48,-131.74 41.48,-131.74 0,0 7.18,-40.53 35.41,-127.15 28.09,-86.21 81.25,-292.94 81.74,-294.88 l -0.03,-0.07 -119.93,-206.33 -0.06,-0.07 c -1.77,-0.23 -111.23,-14.59 -177.75,-32.72 -66.63,-18.18 -97.16,-29.58 -97.56,-29.73 h -0.04 l -333.23,20.15"/></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,169 @@
/* raayoni — grid, filtering, sorting, light/dark.
*
* The grid is Muuri (muuri.dev), as on dominofactory.net. Filtering is by
* discipline (Category) and by engagement (Project) — two independent axes, so
* they compose. Sorting is by year, which spans 2000–2022.
*/
(function () {
"use strict";
/* ── light / dark ──────────────────────────────────────────────────────
* The initial value is applied by an inline script in <head> so there is no
* flash of the wrong ground colour before this file loads. Here we only
* handle the toggle and keep the button label honest.
*/
var root = document.documentElement;
function currentTheme() {
return root.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
}
function paintToggle(btn) {
if (!btn) return;
var dark = currentTheme() === "dark";
btn.textContent = dark ? "light" : "dark";
btn.setAttribute("aria-label", "Switch to " + (dark ? "light" : "dark") + " mode");
}
var toggle = document.querySelector("[data-theme-toggle]");
paintToggle(toggle);
if (toggle) {
toggle.addEventListener("click", function () {
var next = currentTheme() === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
try { localStorage.setItem("theme", next); } catch (e) { /* private mode */ }
paintToggle(toggle);
});
}
/* ── contact address ──────────────────────────────────────────────────────
* The address is stored rot13'd, so the page source contains no string an
* email regex will match, and is written out as a real mailto only once the
* document is live. Without JS the visible fallback stays human-readable
* ("tobias at raayoni-last dot com") rather than silently breaking.
*
* This stops regex harvesters, not a scraper that executes JS. The previous
* version used document.write inside Markdown, which rendered its own source
* as visible text and was neither clickable nor hidden.
*/
Array.prototype.forEach.call(document.querySelectorAll(".mail[data-r]"), function (el) {
var addr = el.getAttribute("data-r").replace(/[a-zA-Z]/g, function (c) {
return String.fromCharCode((c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26);
});
var a = document.createElement("a");
a.href = "mailto:" + addr;
a.textContent = addr;
el.replaceWith(a);
});
/* ── the grid ─────────────────────────────────────────────────────────── */
var el = document.querySelector(".grid");
if (!el || typeof Muuri === "undefined") return;
var grid = new Muuri(el, {
items: ".item",
layout: { fillGaps: false, rounding: true },
layoutDuration: 280,
layoutEasing: "cubic-bezier(.2,.7,.3,1)",
sortData: {
year: function (item, element) {
return parseInt(element.getAttribute("data-year"), 10) || 0;
},
title: function (item, element) {
return (element.getAttribute("data-title") || "").toLowerCase();
}
}
});
/* Tiles are images of wildly varying size fetched from an archive, so the
first layout is usually wrong until they have decoded. Relayout as they
arrive rather than guessing dimensions up front. */
Array.prototype.forEach.call(el.querySelectorAll("img"), function (img) {
if (img.complete) return;
img.addEventListener("load", relayout, { once: true });
img.addEventListener("error", function () {
img.closest(".hoverthumb").classList.add("empty");
relayout();
}, { once: true });
});
var pending = null;
function relayout() {
clearTimeout(pending);
pending = setTimeout(function () { grid.refreshItems().layout(); }, 60);
}
window.addEventListener("load", relayout);
/* ── grouping, filtering, sorting ─────────────────────────────────────── */
/* Headers are grid items too, so they must be hidden whenever the grid is
not in its grouped arrangement — a filtered or re-sorted grid would
otherwise strand them above the wrong tiles. */
function grouped(on) {
if (on) { el.setAttribute("data-grouped", "true"); }
else { el.removeAttribute("data-grouped"); }
}
function isGrouped() { return el.hasAttribute("data-grouped"); }
var active = { category: "", project: "" };
var countEl = document.querySelector("[data-count]");
function apply() {
var filtering = !!(active.category || active.project);
if (filtering) grouped(false);
grid.filter(function (item) {
var e = item.getElement();
if (e.classList.contains("head")) return isGrouped() && !filtering;
var okCat = !active.category || e.getAttribute("data-category") === active.category;
var okPrj = !active.project || e.getAttribute("data-project") === active.project;
return okCat && okPrj;
});
if (countEl) {
var n = grid.getItems().filter(function (i) {
return i.isVisible() && !i.getElement().classList.contains("head");
}).length;
countEl.textContent = n + (n === 1 ? " entry" : " entries");
}
}
Array.prototype.forEach.call(document.querySelectorAll("[data-filter]"), function (btn) {
btn.addEventListener("click", function () {
var axis = btn.getAttribute("data-axis") || "category";
var val = btn.getAttribute("data-filter");
active[axis] = active[axis] === val ? "" : val; /* click again to clear */
document.querySelectorAll('[data-axis="' + axis + '"]').forEach(function (b) {
b.setAttribute("aria-pressed", String(b.getAttribute("data-filter") === active[axis]));
});
apply();
});
});
/* ── sorting ──────────────────────────────────────────────────────────── */
Array.prototype.forEach.call(document.querySelectorAll("[data-sort]"), function (btn) {
btn.addEventListener("click", function () {
var how = btn.getAttribute("data-sort");
if (how === "group") {
active.category = active.project = "";
document.querySelectorAll("[data-filter]").forEach(function (b) {
b.setAttribute("aria-pressed", "false");
});
grouped(true);
grid.sort(function (a, b) { return a._dom - b._dom; }); /* DOM order */
} else {
grouped(false);
grid.sort(how === "year-asc" ? "year:asc" : how === "title" ? "title:asc" : "year:desc");
}
document.querySelectorAll("[data-sort]").forEach(function (b) {
b.setAttribute("aria-pressed", String(b === btn));
});
apply();
});
});
/* The grid is rendered grouped by engagement so that arrangement can be
restored from DOM order; newest is the default view, so sort on init. */
grid.getItems().forEach(function (item, i) { item._dom = i; });
grid.sort("year:desc");
apply();
})();

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Not found · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">404</h1>
<div class="text"><p>That page is not here. Try the <a href="{{ SITEURL }}/">index</a> or the <a href="{{ SITEURL }}/timeline.html">timeline</a>.</p></div>
</div></div>
{% endblock %}

View file

@ -0,0 +1,5 @@
{# The swift mark, as used on invoices. Two nested transforms from the
Inkscape source are both required: the group translate and the path's
own matrix (a 7.5x scale with a Y flip). fill="currentColor" so it takes
var(--title) and inverts with the theme. #}
<svg class="mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 161.36533 268.29031" aria-hidden="true" focusable="false"><g transform="translate(-150.26933,-390.24689)"><path fill="currentColor" transform="matrix(0.13333333,0,0,-0.13333333,0,816)" d="m 1127.08,2003.92 -0.06,-0.3 c 10.57,-3.95 107.03,-39.95 161.26,-55.57 53.65,-15.46 85.22,-23.27 89.12,-24.24 l 0.01,-0.25 c -7.26,-2.64 -111.38,-40.61 -161.15,-64.09 l -1.53,-0.72 c -12.12,-5.72 -6.89,-23.99 6.42,-22.46 75.33,8.66 191.92,21.61 236.48,24.16 67.75,3.87 248.11,-12.47 250.1,-12.65 l 0.03,-0.02 c 0.27,-0.96 11.45,-41.19 -6.82,-68.11 -18.49,-27.25 -180.05,-218.01 -216.07,-265.71 -36,-47.68 -71.04,-112.89 -98.29,-146.96 -27.25,-34.06 -95.38,-115.81 -124.58,-138.2 -29.2,-22.38 -60.34,-41.85 -53.53,-46.71 6.81,-4.87 76.89,6.81 131.39,22.38 54.51,15.58 241.37,109.98 327.99,175.19 86.62,65.2 189.79,154.09 247.21,212.81 57.42,58.73 120.68,135.62 156.7,184.28 35.97,48.62 38.92,62.26 39.89,91.41 l 0.02,0.08 c 1.65,0.34 126.77,26.55 165.44,69.09 l 37.44,41.2 c 0.99,1.08 2.16,1.97 3.47,2.62 l 19.22,9.6 0.02,0.27 -30.93,23.2 -0.02,0.01 c -0.19,0.22 -29.36,36.08 -78.84,59.36 -46.86,22.06 -62.49,25.89 -64.09,26.25 l -0.12,0.1 c 1.18,3.26 42.8,118.27 42.8,158.58 0,40.87 -44.77,157.66 -81.75,229.68 -36.99,72.02 -114.85,222.88 -140.15,264.73 -25.3,41.85 -115.82,175.19 -185.89,241.37 -70.08,66.18 -199.52,190.76 -238.46,198.54 -38.92,7.79 70.08,-134.3 70.08,-162.53 0,-28.23 35.04,-79.81 36.99,-109 1.95,-29.2 26.27,-90.52 30.17,-114.85 3.89,-24.33 41.48,-131.74 41.48,-131.74 0,0 7.18,-40.53 35.41,-127.15 28.09,-86.21 81.25,-292.94 81.74,-294.88 l -0.03,-0.07 -119.93,-206.33 -0.06,-0.07 c -1.77,-0.23 -111.23,-14.59 -177.75,-32.72 -66.63,-18.18 -97.16,-29.58 -97.56,-29.73 h -0.04 l -333.23,20.15"/></g></svg>

View file

@ -0,0 +1,57 @@
{# Provenance fields arrive as "Name <url>", several separated by "; ".
Rendered as a mono credit line — the one slot no off-the-shelf theme has. #}
{% macro names(raw) %}
{%- for part in raw.split(';') %}
{%- set p = part.strip() %}
{%- if '<' in p %}
{%- set nm = p.split('<')[0].strip() %}
{%- set url = p.split('<')[1]|replace('>','')|trim %}
<a href="{{ url }}" rel="noopener">{{ nm }}</a>
{%- else %}{{ p }}{% endif %}
{%- if not loop.last %}, {% endif %}
{%- endfor %}
{%- endmacro %}
{% macro credits(a) %}
{%- set rows = [('for', 'for'), ('hiredby', 'hired by'), ('with', 'with'), ('photosby', 'photos by')] %}
{%- set any = rows | selectattr(0, 'in', a.metadata) | list %}
{%- if any %}
<div class="credits">
{%- for key, label in rows %}{% if a.metadata.get(key) %}
<div><span class="rel">{{ label }}</span> {{ names(a.metadata.get(key)) }}</div>
{%- endif %}{% endfor %}
</div>
{%- endif %}
{%- endmacro %}
{# A tile. `empty` when no thumbnail survived — about 35 entries are in that
position, so it degrades to a typographic card rather than a hole. #}
{% macro tile(a, SITEURL) %}
{# assetcheck flags references whose file did not survive #}
{% set thumb = a.metadata.get('thumb') if not a.metadata.get('thumb_missing') else none %}
<div class="item"
data-category="{{ a.category }}"
data-project="{{ a.metadata.get('project', '') }}"
data-year="{{ a.date.year }}"
data-title="{{ a.title|striptags }}">
<div class="item-content">
{% if thumb %}
{# One image, not two: siblings dim on hover rather than the target
colourising, so there is no need for a greyscale copy. #}
<div class="hoverthumb">
<img src="{{ SITEURL }}{{ thumb }}" alt="{{ a.title|striptags }}" loading="lazy" decoding="async">
</div>
{% else %}
<div class="hoverthumb empty"><span>{{ a.category }}</span></div>
{% endif %}
<div class="content">
<div class="title">{{ a.title }}</div>
<div class="year">{{ a.date.year }}{% if a.metadata.get('project') %} · {{ a.metadata.get('project') }}{% endif %}</div>
{% set d = a.summary|striptags|trim %}
{% if d %}<div class="description">{{ d|truncate(104, true, '…') }}</div>{% endif %}
{% if a.tags %}<div class="tags">{% for t in a.tags %}<span>{{ t }}</span>{% endfor %}</div>{% endif %}
</div>
<a class="cover" href="{{ SITEURL }}/{{ a.url }}" aria-label="{{ a.title|striptags }}"></a>
</div>
</div>
{% endmacro %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Timeline · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">Timeline</h1>
<p class="lede">Everything by date, {{ dates|map(attribute='date.year')|min }}–{{ dates|map(attribute='date.year')|max }}. This is the CV proper.</p>
<ul class="rows">
{% for a in dates %}
<li><a href="{{ SITEURL }}/{{ a.url }}" style="display:flex;gap:1.2rem;align-items:baseline;width:100%">
<span class="yr">{{ a.date.year }}</span>
<span class="ti">{{ a.title }}</span>
<span class="ct">{{ a.category }}{% if a.metadata.get('project') %} · {{ a.metadata.get('project') }}{% endif %}</span>
</a></li>
{% endfor %}
</ul>
</div></div>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% import "_macros.html" as m %}
{% block title %}{{ article.title|striptags }} · {{ SITENAME }}{% endblock %}
{% block meta %}
<meta name="description" content="{{ article.summary|striptags|truncate(180)|e }}">
{% endblock %}
{% block content %}
{% set bg_ok = article.metadata.get('background') and not article.metadata.get('background_missing') %}
{% set th_ok = article.metadata.get('thumb') and not article.metadata.get('thumb_missing') %}
{% set bg = article.metadata.get('background') if bg_ok else (article.metadata.get('thumb') if th_ok else none) %}
{# No hero at all when no image survived: an empty placeholder band is just
dead space above the title. The entry starts under the bar instead. #}
{% if bg %}
<div class="hero" style="background-image:url('{{ SITEURL }}{{ bg }}')">
<div class="scrollcue">scroll</div>
</div>
{% endif %}
<div class="pages">
<article class="frame">
<h1 class="tight">{{ article.title }}</h1>
<div class="entry-meta">
{{ article.date.year }}
· <a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>
{%- if article.metadata.get('project') %} · {{ article.metadata.get('project') }}{% endif %}
{%- if article.tags %} · {% for t in article.tags %}<a href="{{ SITEURL }}/{{ t.url }}">{{ t }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}
</div>
{{ m.credits(article) }}
<div class="text body">
{{ article.content }}
</div>
</article>
</div>
{% endblock %}

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="{{ DEFAULT_LANG }}"{% if THEME_DEFAULT %} data-theme="{{ THEME_DEFAULT }}"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ SITENAME }}{% endblock %}</title>
{% block meta %}{% endblock %}
{# Applied before first paint: without this the wrong ground colour flashes. #}
<script>
(function(){try{var t=localStorage.getItem("theme");
if(t){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();
</script>
<link rel="stylesheet" href="{{ SITEURL }}/theme/css/style.css">
{% if FEED_ALL_ATOM %}
<link rel="alternate" type="application/atom+xml" href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}" title="{{ SITENAME }}">
{% endif %}
</head>
<body>
<nav class="bar">
<a class="me" href="{{ SITEURL }}/">{% include "_logo.html" %}<span>{{ SITENAME }}</span></a>
{% for title, link in MENUITEMS %}<a href="{{ SITEURL }}{{ link }}">{{ title }}</a>
{% endfor %}
<span class="sep">·</span>
<button type="button" data-theme-toggle>dark</button>
</nav>
{% block content %}{% endblock %}
<footer>
{{ SITENAME }}
{% for title, link in MENUITEMS %} · <a href="{{ SITEURL }}{{ link }}">{{ title }}</a>{% endfor %}
</footer>
<script src="{{ SITEURL }}/theme/js/muuri.min.js"></script>
<script src="{{ SITEURL }}/theme/js/site.js"></script>
</body>
</html>

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Disciplines · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">Disciplines</h1>
<div class="cloud">
{% for cat, arts in categories %}
<a href="{{ SITEURL }}/{{ cat.url }}">{{ cat }} <span style="color:var(--sub)">{{ arts|length }}</span></a>
{% endfor %}
</div>
</div></div>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% import "_macros.html" as m %}
{% block title %}{{ category }} · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">{{ category }}</h1>
<p class="lede">{{ articles|length }} {{ 'entry' if articles|length == 1 else 'entries' }}</p>
</div></div>
<div class="grid">
{% for a in articles %}{{ m.tile(a, SITEURL) }}{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% import "_macros.html" as m %}
{# `cv` is the three non-media jobs: a record, not portfolio tiles. #}
{% set shown = articles | rejectattr('category.name', 'equalto', 'cv') | list %}
{% set disciplines = shown | map(attribute='category.name') | unique | sort | list %}
{# Engagements, biggest first. A four-year relationship like ClipAward — nine
entries spanning design, projection, animation and organisation — is the
thing this portfolio has that a flat grid cannot show. Grouping is the
default view; the sort buttons fall back to a flat grid. #}
{% set names = [] %}
{% for a in shown if a.metadata.get('project') and a.metadata.get('project') not in names %}
{% if names.append(a.metadata.get('project')) %}{% endif %}
{% endfor %}
{% set groups = [] %}
{% for n in names %}
{% set members = shown | selectattr('metadata.project', 'equalto', n) | list %}
{% if groups.append({'name': n, 'members': members, 'n': members|length,
'first': members | map(attribute='date.year') | min}) %}{% endif %}
{% endfor %}
{# biggest engagement first, then earliest — Articles are not orderable, so
sort on the counts rather than on the member lists #}
{% set groups = groups | sort(attribute='first') | sort(attribute='n', reverse=true) %}
{% set loose = shown | rejectattr('metadata.project') | list %}
{% block content %}
<section class="controls" aria-label="Filter">
<span class="label">discipline</span>
{% for d in disciplines %}
<button type="button" data-axis="category" data-filter="{{ d }}" aria-pressed="false">{{ d }}</button>
{% endfor %}
<span class="count" data-count>{{ shown|length }} entries</span>
</section>
<section class="controls" aria-label="Arrangement">
<span class="label">arrange</span>
<button type="button" data-sort="year-desc" aria-pressed="true">newest</button>
<button type="button" data-sort="group" aria-pressed="false">by engagement</button>
<button type="button" data-sort="year-asc" aria-pressed="false">oldest</button>
<button type="button" data-sort="title" aria-pressed="false">a–z</button>
</section>
<div class="grid">
{% for g in groups %}
{% set yrs = g.members | map(attribute='date.year') | list %}
{% set kinds = g.members | map(attribute='category.name') | unique | sort | list %}
<div class="item head" data-head="{{ g.name }}">
<div class="item-content">
<div class="grouphead">
<span class="gname">{{ g.name }}</span>
<span class="gmeta">{{ g.members|length }} entries · {{ yrs|min }}{% if yrs|min != yrs|max %}–{{ yrs|max }}{% endif %} · {{ kinds|join(', ') }}</span>
</div>
</div>
</div>
{% for a in g.members|sort(attribute='date') %}{{ m.tile(a, SITEURL) }}{% endfor %}
{% endfor %}
{% if loose %}
<div class="item head" data-head="">
<div class="item-content">
<div class="grouphead">
<span class="gname">Single commissions</span>
<span class="gmeta">{{ loose|length }} entries</span>
</div>
</div>
</div>
{% for a in loose|sort(attribute='date', reverse=true) %}{{ m.tile(a, SITEURL) }}{% endfor %}
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ page.title|striptags }} · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages">
<article class="frame">
{% if page.slug == 'about' %}<div class="markplate">{% include "_logo.html" %}</div>{% endif %}
<h1 class="tight">{{ page.title }}</h1>
<div class="text body">{{ page.content }}</div>
</article>
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% import "_macros.html" as m %}
{% block title %}{{ tag }} · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">{{ tag }}</h1>
<p class="lede">Entries that also involved {{ tag }}.</p>
</div></div>
<div class="grid">
{% for a in articles %}{{ m.tile(a, SITEURL) }}{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Tags · {{ SITENAME }}{% endblock %}
{% block content %}
<div class="pages"><div class="frame">
<h1 class="tight">Tags</h1>
<div class="cloud">
{% for tag, arts in tags|sort %}
<a href="{{ SITEURL }}/{{ tag.url }}">{{ tag }} <span style="color:var(--sub)">{{ arts|length }}</span></a>
{% endfor %}
</div>
</div></div>
{% endblock %}