diff --git a/src/index.html b/src/index.html
index 7709d42..7716bea 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,18 +1,7 @@
-
-
-
-
- $ Welcome to tlater.net!
+ $
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 0a32b9a..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import jQuery from "jquery";
-
-// Helpers
-
-/**
- * "Types" out a DOM element, emulating the way a human might.
- */
-class Typer {
- private element: JQuery;
- private text: string;
- private cursor: boolean;
- private typed: number;
- private min: number;
- private max: number;
- private blink_tick: number;
- private blink_timeout: number;
- private end?: number;
-
- /**
- * Create the typer.
- * @param {HTMLElement} element - The element to type.
- * @param {number} blink - The time between cursor blinks.
- * @param {number} blink_timeout - How long the cursor should keep
- * blinking for after the text
- * finishes typing.
- */
- constructor(element: JQuery, blink: number, blink_timeout: number) {
- // Retrieve the current content and wipe it. We also make the
- // element visible if it was hidden.
- this.element = element;
- this.text = this.element.html();
- this.element.html("");
- this.element.css("visibility", "visible");
-
- this.cursor = false;
- this.typed = 0;
-
- this.min = 20;
- this.max = 70;
- this.blink_tick = blink;
- this.blink_timeout = blink_timeout;
-
- this.end = null;
- }
-
- /**
- * Start typing.
- */
- type() {
- this._type();
- this._blink();
- }
-
- /**
- * Draw the current text line, i.e., anything that has been typed
- * so far, and a cursor if it is currently supposed to be on.
- * @private
- */
- _draw() {
- let text = this.text.slice(0, this.typed);
-
- if (this.cursor) {
- text += "\u2588";
- }
-
- window.requestAnimationFrame(() => this.element.html(text));
- }
-
- /**
- * Type the next character, and prepare to draw the next one. If
- * no new characters are to be drawn, set the end timestamp.
- * @private
- */
- _type() {
- this.typed += 1;
- this._draw();
-
- if (this.typed != this.text.length)
- setTimeout(this._type.bind(this), this._type_tick());
- else {
- this.end = Date.now();
- }
- }
-
- /**
- * Make the cursor change blink status, and prepare for the next
- * blink.
- * @private
- */
- _blink() {
- this.cursor = !this.cursor;
- this._draw();
-
- // As long as we are typing, keep blinking
- if (this.typed != this.text.length)
- setTimeout(this._blink.bind(this), this.blink_tick);
- // Once typing ends, keep going for a little bit
- else if (Date.now() - this.end < this.blink_timeout)
- setTimeout(this._blink.bind(this), this.blink_tick);
- // Make sure we get rid of the cursor in the end
- else {
- this.cursor = true;
- setTimeout(this._blink.bind(this), this.blink_tick);
- }
- }
-
- /**
- * Calculate a "human" time for the next character to type.
- * @private
- */
- _type_tick() {
- return Math.round(Math.random() * this.max) + this.min;
- }
-}
-
-jQuery(($) => {
- const typer = new Typer($(".head-line .typed").first(), 500, 3000);
- typer.type();
-});
diff --git a/src/lib/js/main.ts b/src/lib/js/main.ts
deleted file mode 100644
index a9894f5..0000000
--- a/src/lib/js/main.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import jQuery from "jquery";
-
-jQuery(($) => $("html").removeClass("no-js"));
diff --git a/src/lib/scss/_typed.scss b/src/lib/scss/_typed.scss
new file mode 100644
index 0000000..1082e72
--- /dev/null
+++ b/src/lib/scss/_typed.scss
@@ -0,0 +1,130 @@
+@use "sass:math";
+@use "sass:list";
+
+/// Animate a blinking cursor.
+@mixin cursor($duration) {
+ $name: cursor-09d03260130069771b6ddc1cb415f39fdd27ddfab7b01ba91273398c2d245ae4;
+ // The number of times we need to blink is = the number of full
+ // seconds (500ms * 2) that fit in the total duration, rounded up,
+ // and doubled.
+ $iterations: math.ceil(math.div($duration, 1s)) * 2;
+
+ animation: $name ease-in-out 500ms $iterations alternate;
+ content: " ";
+
+ @keyframes #{$name} {
+ from {
+ content: " ";
+ }
+
+ to {
+ content: "█";
+ }
+ }
+}
+
+/// Animate a piece of text as if it was being typed by a human.
+@mixin typed($text, $duration) {
+ // We don't want a linearly typed set of text, which makes this
+ // singificantly more complex.
+ //
+ // CSS animations normally do not permit per-frame changes in
+ // duration (since the total animation time is fixed). This means we
+ // need to create multiple animations, and delay them so that they
+ // happen in the time sequence we want.
+ //
+ // We generate the raw values with _generate-animations, and then
+ // split up the result into the animation API.
+ $frames: str-length($text);
+ $animations: _generate-animations($frames, 1.2s);
+
+ animation-name: _unzip($animations, 1);
+ animation-delay: _unzip($animations, 3);
+ animation-fill-mode: forwards;
+ content: "";
+
+ // We need to type each character in separate animations, see above
+ // comment.
+ @each $name, $character in $animations {
+ @keyframes #{$name} {
+ from {
+ content: str-slice($text, 0, $character);
+ }
+
+ to {
+ content: str-slice($text, 0, $character + 1);
+ }
+ }
+ }
+}
+
+/// Unzip a nested set of lists, taking the nth value of each sublist.
+@function _unzip($lists, $i) {
+ $out: ();
+ $sep: comma;
+ @each $sublist in $lists {
+ $out: list.append($out, list.nth($sublist, $i), $sep);
+ }
+ @return $out;
+}
+
+/// Compute the sum of all numbers in a list.
+@function _sum($list) {
+ $out: 0;
+ @each $val in $list {
+ $out: $out + $val;
+ }
+ @return $out;
+}
+
+/// Produce a list from a shorter list by repeating it up until size
+/// $length.
+@function _round-robin($base, $length) {
+ $out: ();
+ $sep: list.separator($out);
+ @for $i from 0 through $length {
+ $out: list.append($out, list.nth($base, $i % list.length($base) + 1));
+ }
+ @return $out;
+}
+
+/// Generate the actual animation values.
+///
+/// This generates a nested list as:
+///
+/// (keyframe-name, index, start time)
+///
+/// The duration of each frame is taken from the internal $delays in a
+/// round robin fashion, to give some amount of human-like variance to
+/// the duration of each frame.
+///
+/// Start time is set to the time at which the frame should start to
+/// achieve the desired frame-by-frame duration.
+@function _generate-animations($number, $total_duration) {
+ $id: d66fa0449c0b4d4ca287f8c96428af928b2987b4d88b72b7d60152d9a55d9f29;
+ $out: ();
+ $sep: list.separator($out);
+
+ // A set of "human-like" delays for each typed character. In
+ // practice, my typing seems to be about 20-70ms, but it looks a bit
+ // nicer to increase all typing by 20ms to make the effect more
+ // noticeable.
+ //
+ // Numbers generated once with a random number generator, rather
+ // than using `math.random()`, since they end up in CSS verbatim,
+ // and the build would be non-reproducible if we didn't do it this
+ // way. Using `math.random() wouldn't change this dynamically each
+ // time the page loads anyway, so we don't really lose anything by
+ // pre-generating these numbers.
+ $delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms;
+
+ @for $animation from 0 through $number {
+ $out: list.append($out, (
+ type-#{$id}-#{$animation},
+ $animation,
+ _sum(_round_robin($delays, $animation))
+ ), $sep);
+ }
+
+ @return $out;
+}
diff --git a/src/lib/scss/main.scss b/src/lib/scss/main.scss
index 889905e..3b0b16f 100644
--- a/src/lib/scss/main.scss
+++ b/src/lib/scss/main.scss
@@ -1,6 +1,17 @@
@import "./_custom-bulma";
+@import "./_typed";
html, body {
height: 100%;
width: 100%;
}
+
+#typed-welcome {
+ &::before {
+ @include typed("Welcome to tlater.net!", 1.2s);
+ }
+
+ &::after {
+ @include cursor(6s);
+ }
+}