diff --git a/public/assets/css/style.css b/public/assets/css/style.css index d67f248..988ca5f 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1,205 +1,239 @@ -:root { - --color-base: 0, 0, 0; - --color-contrast: 256, 256, 256; +/* -- Cornerstones -- */ - --padding: clamp(40px, 2vw, 2vw); - --border-size: clamp(4px, .25vw, .25vw); +:root { + --primer-color-base: 255, 255, 255; + --primer-color-contrast: 0, 0, 0; + + --color-base: rgb(var(--primer-color-base)); + --color-contrast: rgb(var(--primer-color-contrast)); + + --padding: 20px; } -/* -- Cornerstones -- */ +@font-face { + font-family: "Roboto Mono"; + src: + local("Roboto Mono"), + url("../fonts/roboto-mono.woff2") format("woff2"), + url("../fonts/roboto-mono.ttf") format("ttf"); + font-display: fallback; +} * { margin: 0; - font-family: "Monaco", "Consolas", monospace, sans-serif; - color: rgb(var(--color-contrast)); + box-sizing: border-box; + font-family: "Roboto Mono", "Courier", sans-serif; + font-size: 20px; + color: inherit; } -*::selection { - background-color: rgb(var(--color-contrast)); - color: rgb(var(--color-base)); +::selection { + background-color: var(--color-contrast); + color: var(--color-base); } -html, -body { - width: 100%; - height: 100%; - overflow-x: hidden; +::placeholder { + color: rgba(var(--primer-color-contrast), .5); } html { - background-color: rgba(var(--color-base), .7); background-size: cover; - background-blend-mode: overlay; - background-position: center; - background-attachment: fixed; + background-position: 50% 50%; } -picture { - display: contents; +body { + display: flex; + flex-direction: column; + align-items: center; + color: var(--color-contrast); + background-color: rgba(var(--primer-color-base), .95); + gap: var(--padding); + padding: var(--padding) 0; + padding-bottom: 30vh; } -h1 { - font-size: clamp(45px, 7vw, 6vh); -} - -p, a { - font-size: clamp(20px, 3vw, 2vh); - text-align: justify; +a:not(p > a) { + text-decoration: none; } /* -- Components -- */ -body { +input { + letter-spacing: 5px; +} + +input, +.button { + background-color: var(--color-contrast); + color: var(--color-base); + padding: calc(var(--padding) / 2) calc(var(--padding) * 1.5); + text-align: left; display: flex; - flex-direction: column; align-items: center; - justify-items: center; - gap: var(--padding, 30px); + justify-content: center; + gap: var(--padding); + height: 3em; } -body > div { - padding: calc(var(--padding) / 2); +input, +.button.phantom { + background-color: transparent; + border: solid 2px var(--color-contrast); + color: var(--color-contrast); } -:is(#intro, #card) a { - --padding-vert: clamp(17px, 1.1vw, 1.1vw); - - display: inline-block; - text-decoration: none; - text-align: center; +img, +.button { user-select: none; - background-color: rgba(var(--color-contrast), .13); - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); - box-shadow: - inset 0 .3vh 1.6vh rgba(0, 0, 0, 0), - 0 .1vh .3vh rgba(0, 0, 0, .12), - 0 .1vh .2vh rgba(0, 0, 0, .24); } -/* --- */ - -#intro { - padding: calc(var(--padding) / 2); +.button :not(p) { + height: 2em; } -#intro a { - padding: var(--padding-vert) 2vw; - border-radius: 100px; - border: solid var(--border-size) rgba(var(--color-contrast), 0); +.interact::before { + content: "tap "; +} + +/* ---- */ + +.spacer { + display: grid; + justify-items: center; +} + +body > .spacer { margin: var(--padding) 0; - width: calc(100% - ((var(--padding) / 2) + var(--border-size))); } -#intro p { - margin: 1vh 0; - font-size: clamp(20px, 3vw, 3vh); +.spacer div { + width: 205px; + height: 7px; + background-color: var(--color-contrast); + transform: rotate(-4deg); } -/* --- */ +/* -- Content -- */ -#card, -#card > div { +form, +section { + max-width: 600px; + margin: var(--padding) calc(var(--padding) * 1.5); display: flex; flex-direction: column; align-items: center; - gap: calc(var(--padding) / 2); + text-align: justify; + gap: var(--padding); } -#card { - --portrait-size: clamp(128px, 12vw, 12vh); - - position: relative; - max-width: 600px; - padding: var(--padding); - border-radius: clamp(18px, 1vw, 1vw); - backdrop-filter: saturate(100) brightness(.4); - -webkit-backdrop-filter: saturate(100) brightness(.4); - border: solid var(--border-size) rgba(var(--color-contrast), .1); - box-shadow: 0 1vh 2vh rgba(0, 0, 0, .19), 0 6px 6px rgba(0, 0, 0, .23); -} - -#card img { - width: var(--portrait-size); - height: var(--portrait-size); - position: absolute; - border-radius: 100%; - top: calc(((var(--portrait-size) / 2) + var(--border-size)) * -1); - background-color: rgb(var(--color-base)); - box-shadow: 0 1vh 2vh rgba(0, 0, 0 , .19), 0 6px 6px rgba(0, 0, 0 , .23); -} - -#card a { +section > *, +section picture > img { width: 100%; - padding: var(--padding-vert) 0; - margin-top: calc(var(--padding) / 2); - border-radius: clamp(9px, .5vw, .5vw); } -/* -- Media Queries -- */ +section#code { + text-align: center; +} -@supports ((not ((backdrop-filter: saturate(1)) and (backdrop-filter: brightness(1)))) and (not ((-webkit-backdrop-filter: saturate(1)) and (-webkit-backdrop-filter: brightness(1))))) { - #card { - background-color: rgba(var(--color-base), .7); +section#intro { + max-width: 250px; + text-align: center; +} + +section#contact > div { + display: grid; + grid-template-rows: repeat(2, 1fr); + gap: var(--padding); +} + +section#sky .button img { + height: 50%; +} + +section#coffee > img { + width: 100px; + margin-bottom: calc(var(--padding) * 2); +} + +/* ---- */ + +.banner { + width: 100%; + background-color: rgba(var(--primer-color-contrast), .05); + text-align: center; + padding: calc(var(--padding) / 2) 0; + margin: var(--padding) 0; +} + +/* -- Media queries -- */ + +@media (hover: hover) { + .button:hover { + color: var(--color-contrast); + background-color: transparent; + border: inset 2px black; + } + + .button:hover:not(.phantom) :is(img, svg) { + filter: invert(1); + } + + .button:active { + background-color: rgba(var(--primer-color-contrast), .1); + } + + /* ---- */ + + .button.phantom:hover { + background-color: rgba(var(--primer-color-contrast), .05); + } + + .button.phantom:active { + background-color: rgba(var(--primer-color-contrast), .1); } } @media (pointer: fine) { - :is(#intro, #card) a { - --transition-speed: 200ms; - transition: - var(--transition-speed) background-color, - var(--transition-speed) box-shadow, - var(--transition-speed) border-color; + .button { + cursor: pointer; } - :is(#intro, #card) a:hover { - background-color: rgba(var(--color-contrast), .2); - border-color: rgba(var(--color-contrast), .2); - box-shadow: - inset 0 .3vh 1.6vh rgba(0, 0, 0, .16), - 0 .3vh .6vh rgba(0, 0, 0, .16), - 0 .3vh .6vh rgba(0, 0, 0, .23); + .interact::before { + content: "click "; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --primer-color-base: 0, 0, 0; + --primer-color-contrast: 255, 255, 255; } - :is(#intro, #card) a:active { - background-color: rgba(var(--color-contrast), .15); + img:not(picture img) { + filter: invert(1); } } @media (max-width: 330px) { - p, a { - text-align: left; - font-size: 18px; - } - - #card { - padding: calc(var(--padding) / 2); + section#sky .button img { + display: none; } } -@media (min-aspect-ratio: 14/9) and (min-height: 600px) { - body { +@media (min-width: 660px) { + input, + .button { + height: 3.5em; + } + + section#code { display: grid; + grid-template-columns: 1fr 40px 1fr; + width: 100%; + } + + section#contact > div { + grid-template-rows: 1fr; grid-template-columns: repeat(2, 1fr); - gap: unset; } - - body > div { - display: grid; - align-items: center; - } - - body > div:last-of-type { - padding: calc(var(--padding) * 2); - } - - #intro a { - width: unset; - } - - #card { - min-width: 300px; - max-width: 30vw; - } -} +} \ No newline at end of file diff --git a/public/assets/fonts/roboto-mono.ttf b/public/assets/fonts/roboto-mono.ttf new file mode 100644 index 0000000..de0f485 Binary files /dev/null and b/public/assets/fonts/roboto-mono.ttf differ diff --git a/public/assets/fonts/roboto-mono.woff2 b/public/assets/fonts/roboto-mono.woff2 new file mode 100644 index 0000000..f8894ba Binary files /dev/null and b/public/assets/fonts/roboto-mono.woff2 differ diff --git a/public/assets/js/modules/Dialog.mjs b/public/assets/js/modules/Dialog.mjs new file mode 100644 index 0000000..21f81b8 --- /dev/null +++ b/public/assets/js/modules/Dialog.mjs @@ -0,0 +1,217 @@ +export class Dialog { + constructor(target, options = {}) { + this.dialog = document.createElement("dialog"); + + // Center the dialog modal + this.setStyle(this.dialog, { + "margin": "auto", + "display": "flex", + "flex-direction": "column", + "gap": "var(--padding)" + }); + + this.content = document.createElement("div"); + this.content.classList.add("content"); + + // Use only as wrapper + this.setStyle(this.content, { + "display": "contents" + }); + + // Close modal when open attribute gets removed + this.observer = new MutationObserver(mutation => { + if (mutation[0].attributeName === "open") { + this.closed(); + } + }); + + // HTMLDialogElement is not supported, use an alert() instead + if (typeof this.dialog.showModal !== "function") { + this.dialog = { + title: "", + content: "" + } + } + + // Append header content + if ("header" in options) { + this.header(options.header); + } + + // Bind events and append to DOM + if (this.dialog instanceof HTMLDialogElement) { + this.dialog.addEventListener("click", event => { + const size = event.target.closest("dialog").getBoundingClientRect(); + + // If click happened on the area surrounding the dialog (lazy dismiss) + if (event.x < size.left || event.y < size.top || event.x > size.right || event.y > size.bottom) { + this.close(); + } + }); + + document.body.appendChild(this.dialog); + } + } + + setStyle(target, props) { + // Get element by selector + if (!target instanceof HTMLElement) { + target = document.querySelector(target); + } + + // Set CSS properties + for (const [name, value] of Object.entries(props)) { + target.style.setProperty(name, value); + } + } + + // Set dialog header + header(header) { + // Remove existing header wrapper + let element = this.dialog.getElementsByClassName("header")[0] ?? null; + if (element) { + element.remove(); + } + + const size = "50px"; + + // Create header wrapper + element = document.createElement("div"); + element.classList.add("header"); + this.setStyle(element, { + "display": "grid", + "grid-template-columns": `1fr ${size}`, + "align-items": "center", + "padding": "var(--padding)", + "gap": "var(--padding)", + "background-color": "rgba(var(--primer-color-contrast), .05)" + }); + + // Append a header text + if ("title" in header) { + const title = document.createElement("h1"); + title.innerText = header.title; + this.setStyle(title, { + "margin-left": "calc(var(--padding) / 2)" + }); + + element.appendChild(title); + } + + // Append a close button + if ("closeButton" in header && header.closeButton === true) { + const closeButton = document.createElement("div"); + closeButton.classList.add("button"); + closeButton.innerHTML = ''; + this.setStyle(closeButton, { + "width": size, + "height": size, + "padding": "calc(var(--padding) / 1.25)" + }); + + closeButton.addEventListener("click", () => this.close()); + element.appendChild(closeButton); + } + + this.dialog.insertAdjacentElement("afterbegin", element); + } + + // Remove content element subtree + clear() { + if (!this.dialog instanceof HTMLDialogElement || !this.content) { + return false; + } + + while (this.content.lastChild) { + this.content.lastChild.remove(); + } + } + + error(title = "Something went wrong", message = "Unknown error", data = null) { + this.header({ + title: title, + closeButton: true + }); + + this.clear(); + + const info = document.createElement("p"); + info.innerText = message; + + this.content.appendChild(info); + + // Has detailed information about error + if (data) { + // Create the element which, when clicked, will show data + const dump = document.createElement("p"); + dump.classList.add("interact"); + dump.innerText = "here for technical data"; + this.setStyle(dump, { "text-decoration": "underline" }); + + // Show detailed error data + dump.addEventListener("click", () => { + dump.classList.remove("interact"); + dump.innerText = `Returned: "${data}"`; + + this.setStyle(dump, { + "text-decoration": "initial", + "background-color": "rgba(var(--primer-color-contrast), .05)", + "padding": "var(--padding)" + }); + }, { once: true }); + + this.content.appendChild(dump); + } + } + + // Open modal with embedded page or text + open(target) { + let source; + + // Check if the thing to open is a page or some text + try { + source = target instanceof HTMLAnchorElement ? new URL(target.href) : new URL(target); + + // Perform top-level navigation instead if HTMLDialogElement is not supported + if (!this.dialog instanceof HTMLDialogElement) { + window.location.href = source.toString(); + } + + this.header({ + title: target.hasAttribute("title") ? target.getAttribute("title") : "", + closeButton: true + }); + + // Fetch page from URL and inject it into dialog DOM + this.content.innerHTML = "

⌛ Loading...

"; + fetch(source) + .then(res => res.text()) + .then(page => this.content.innerHTML = page); + } catch { + // Looks like we're just getting text + this.content.innerText = target; + } + + this.dialog.showModal(); + this.dialog.appendChild(this.content); + + this.observer.observe(this.dialog, { + attributes: true + }); + } + + // Destroy dialog + closed() { + this.observer.disconnect(); + this.dialog.remove(); + } + + // Close dialog + close() { + if (!this.dialog instanceof HTMLDialogElement) { + return false; + } + + this.dialog.close(); + } +} \ No newline at end of file diff --git a/public/assets/js/modules/Player.mjs b/public/assets/js/modules/Player.mjs new file mode 100644 index 0000000..c6cf018 --- /dev/null +++ b/public/assets/js/modules/Player.mjs @@ -0,0 +1,37 @@ +// Create a new AudioPlayer from template +export class AudioPlayer extends Audio { + constructor(target) { + if (!target instanceof HTMLElement) { + throw new Error("Target must be an HTMLElement"); + } + + const src = "src" in target.dataset ? target.dataset.src : ""; + super(src); + + // Bind interaction with the player as play/stop + target.addEventListener("click", () => this.playState()); + + // Start playback when ready + this.addEventListener("canplaythrough", () => this.play(), { once: true }); + // Restart playback if paused (treat as stop) + this.addEventListener("pause", () => this.currentTime = 0); + } + + // Play or stop media + playState() { + // Ignore if media is still buffering + if (this.readyState !== HTMLMediaElement.HAVE_ENOUGH_DATA) { + return false; + } + + // Stop media if playing + if (this.paused === false && this.ended === false) { + this.pause(); + this.currentTime = 0; + return false; + } + + // Play media + return this.play(); + } +} \ No newline at end of file diff --git a/public/assets/js/modules/TimeUpdate.mjs b/public/assets/js/modules/TimeUpdate.mjs new file mode 100644 index 0000000..197843e --- /dev/null +++ b/public/assets/js/modules/TimeUpdate.mjs @@ -0,0 +1,87 @@ +// Update timed DOM components +export default class TimeUpdate { + constructor() { + this.date = new Date(); + + this.myTime(); + this.skyPicture(); + this.skyAge(); + } + + // Set the innerText of an Element by its id + setTextById(id, text) { + const element = document.getElementById(id) ?? null; + if (!element) { + return false; + } + + element.innerText = text; + } + + // Set the time in the #intro section (normal clock display) + myTime() { + let time = [ + (this.date.getUTCHours() + 2) % 24, // Get hours + (this.date.getUTCMinutes()) % 60, // Get minutes + ]; + + // Prepend 0 for single-digit numbers + time = time.map(x => { + return x < 10 ? "0" + x : x; + }); + + // Concat hour and minutes with semicolon seperator + this.setTextById("time", time.join(":")); + } + + // Update the sky picture from endpoint + skyPicture() { + const image = document.querySelector("#sky > picture") ?? null; + if (!image) { + return false; + } + + // Helper function to prepare a cache breaking URL + const taggedUrl = (url) => { + url = new URL(url); + // Use current unix epoch as a search param + url.searchParams.set("t", this.date.valueOf()); + + return url.toString(); + } + + // Reload sky picture (using cache breaking URL) + [...image.children].forEach(child => { + switch (child.constructor) { + case HTMLSourceElement: + child.srcset = taggedUrl(child.srcset); + break; + + case HTMLImageElement: + child.src = taggedUrl(child.src); + break; + } + }); + } + + /* + * A new picture of the sky is taken at every 10th minute. + * So round the age down to the nearest 10 minute mark. + */ + skyAge() { + let age = this.date.getMinutes() % 10; + + if (age <= 1) { + /* + * It sometimes takes the server a little longer to transcode + * the picture, so give it an around 1 minute text - for 2 minutes. + */ + age = "~1 minute" + } else { + // Plural suffix + age += " minutes"; + } + + this.setTextById("sky_age", age); + } +} \ No newline at end of file diff --git a/public/assets/js/glitch/Generator.mjs b/public/assets/js/modules/glitch/Generator.mjs similarity index 100% rename from public/assets/js/glitch/Generator.mjs rename to public/assets/js/modules/glitch/Generator.mjs diff --git a/public/assets/js/glitch/Glitch.mjs b/public/assets/js/modules/glitch/Glitch.mjs similarity index 100% rename from public/assets/js/glitch/Glitch.mjs rename to public/assets/js/modules/glitch/Glitch.mjs diff --git a/public/assets/js/glitch/GlitchWorker.js b/public/assets/js/modules/glitch/GlitchWorker.js similarity index 100% rename from public/assets/js/glitch/GlitchWorker.js rename to public/assets/js/modules/glitch/GlitchWorker.js diff --git a/public/assets/js/noscript.js b/public/assets/js/noscript.js index b265ca3..e69de29 100755 --- a/public/assets/js/noscript.js +++ b/public/assets/js/noscript.js @@ -1,6 +0,0 @@ -const search = document.getElementById("search").children[0]; -const results = document.getElementById("results").children[0]; - -search.style.setProperty("display","none"); -results.classList.add("error"); -results.innerText = "Sorry, your browser isn't supported yet"; \ No newline at end of file diff --git a/public/assets/js/script.mjs b/public/assets/js/script.mjs index 2592a11..4673a90 100644 --- a/public/assets/js/script.mjs +++ b/public/assets/js/script.mjs @@ -1,14 +1,75 @@ -import { default as Glitch } from "./glitch/Glitch.mjs"; +import TimeUpdate from "./modules/TimeUpdate.mjs"; -const logging = "https://victorwesterlund-logging-dnzfgzf6za-lz.a.run.app"; +// Create AudioPlayers from template on interaction +[...document.getElementsByClassName("player")].forEach(player => { + player.addEventListener("click", () => { + import("./modules/Player.mjs").then(mod => { + // Initialize AudioPlayer from template + const audioPlayer = new mod.AudioPlayer(player); + let animation; -// Log link clicks -for(let link of document.getElementsByTagName("a")) { - link.addEventListener("click", event => { - event.preventDefault(); - navigator?.sendBeacon(logging, event); - window.location.href = event.target.href; + // Animate a progress bar as media is playing + audioPlayer.addEventListener("playing", () => { + const color = "rgba(var(--primer-color-contrast), .1)"; + + const keyframes = [ + { boxShadow: `inset 0 0 0 0 ${color}` }, + { boxShadow: `inset ${player.offsetWidth}px 0 0 0 ${color}` } + ]; + + const timing = { + duration: 38000, // Robot36 TX + calibration header + iterations: 1 + } + + animation = player.animate(keyframes, timing); + player.querySelector(".playstate").innerText = "stop"; + }); + + // Stop animation if playback is interrupted + audioPlayer.addEventListener("pause", () => { + animation.cancel(); + player.querySelector(".playstate").innerText = "play"; + }); + }); + }, { once: true }); +}); + +// Log button clicks +[...document.getElementsByClassName("button")].forEach(button => { + if ("sendBeacon" in navigator) { + // Get endpoint from dns-prefetch tag + const endpoint = document.querySelector('[data-id="logging"]').href; + button.addEventListener("click", event => navigator.sendBeacon(endpoint, event)); + } +}) + +// Get coffee stats from endpoint +{ + // Get endpoint from preconnect tag + const endpoint = new URL(document.querySelector('[data-id="coffee"]').href); + + fetch(endpoint) + .then(res => res.json()) + .then(json => { + const values = json.Values.flat(1); + const targets = [...document.getElementsByClassName("coffee")]; + + // Assign each value in order of appearance + values.forEach((value, idx) => {targets[idx].innerText = value}); }); } -window.glitch = new Glitch(document.body.parentElement); \ No newline at end of file +// Update timed DOM components every absolute 10th minute +{ + const now = (new TimeUpdate()).date.valueOf(); + + const coeff = 1000 * 60; + const next = Math.ceil((now / coeff)) * coeff; + const offset = next - now; + + // Update timed elements at (roughly) every 10th minute in each absolute hour + window._timeUpdate = setTimeout(() => { + window._timeUpdate = setInterval(() => new TimeUpdate(), 60000); + }, offset); +} \ No newline at end of file diff --git a/public/assets/media/close.svg b/public/assets/media/close.svg new file mode 100644 index 0000000..515ce90 --- /dev/null +++ b/public/assets/media/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/media/coffee.svg b/public/assets/media/coffee.svg new file mode 100644 index 0000000..ec23c1a --- /dev/null +++ b/public/assets/media/coffee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/media/github.svg b/public/assets/media/github.svg new file mode 100644 index 0000000..c869ac1 --- /dev/null +++ b/public/assets/media/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/media/wave.svg b/public/assets/media/wave.svg new file mode 100644 index 0000000..312e8ef --- /dev/null +++ b/public/assets/media/wave.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/contact.html b/public/contact.html new file mode 100755 index 0000000..abefd74 --- /dev/null +++ b/public/contact.html @@ -0,0 +1,31 @@ + + + + + Victor Westerlund + + + + + + + + + + + + + + + + + + +
+

victor westerlund

+
+
+
+
+ + diff --git a/public/error.html b/public/error.html index aa66c8f..d489ab4 100755 --- a/public/error.html +++ b/public/error.html @@ -10,19 +10,48 @@ - - - - + + + + + -
-
-

there is nothing here

-

and that's all I know

- take me home -> -
-
- +
+

Oh, I’m in the wrong place, man!

+

404 - Not Found

+
+
+
+

+ +
+

Get me out of here!

+
+
+
+ diff --git a/public/humans.txt b/public/humans.txt new file mode 100755 index 0000000..9e3aeae --- /dev/null +++ b/public/humans.txt @@ -0,0 +1 @@ +This website was created by me, Victor Westerlund. It's not much but it's honest work. \ No newline at end of file diff --git a/public/index.html b/public/index.html index aa2a371..3744c9d 100755 --- a/public/index.html +++ b/public/index.html @@ -6,44 +6,95 @@ + - + + + + + - + + -
-
-

hello, my name is

-

victor

-

I'm a

-

full-stack

-

developer

-

from Sweden

- my github -> +
+

victor westerlund

+
+
+

I make things with code and coffee

+
+
+ +
+ GitHub +

on github

+
+
+

and

+ +
+

elsewhere

+
+
+
+
+

Wanna chat? The best way to get in touch with me is by sending a mail. The time is currently 00:00 in Sweden, and I will reply as soon as possible.

+
+ +
+

copy email address

+
+
+ +
+

or use this form

+
+
+
+
+ -
-
- - - - - portrait of victor - -
-

I create things with code. When I'm not creating things with code, I enjoy skiing, watching movies and some occasional gaming

-

And beyond computer science, I'm also an armchair rabbit-holer for engineering, physics and astronomy

+
+ + + Webcam stillframe of the sky from my balcony + +

Do you have an SSTV receiver? Tune in to a sneaky surprise and this beautiful view of the sky from my balcony as it looked like - minutes ago. Or why not check out this pretty neat time-lapse.

+ +
+

view timelapse

-
-

...and ☕, full-time

-
- +
+
+ +

Robot36
to play

+

⚠️ warning: loud f*cking noise.

+
+
+
+
+
+ Hand-drawn coffee icon +

Did I mention that I like coffee? Well in the past 7 days I have enjoyed a baffling ? cups of coffee, which is roughly equivalent to ? cups per day! ? impressive given that my weekly average per month is ? cups per day.

+
+
+
+
+
+

Enter your top sneaky code here. You will know when you've struck the jackpot.

+ +

So you don't have a sneaky code?
Solve this puzzle and I'll buy you a cup of coffee.

+
+
+