colophon

Because I like fiddling with websites, I set myself some challenges when building this one:

This page collects all the tricks I’ve used to make that work.

Design

In college, I noticed that one of my roommates—also a math major who hadn’t previously displayed any artistic inclinations—had a beautiful personal site.

“Wow, how did you get it to look so good?” I asked. “Teach me your web design secrets!”

“I’m terrible at web design and I don’t have a secret,” he said. “You just have to be really obsessive about it.”

(I think about this exchange a lot.)

Performance

I learned a lot from living in Jigjiga, Ethiopia, where I often had 10-second ping times and 10%+ packet loss. On this type of connection much of the web is unusable. Fortunately, as a site designer, by caring a bit it’s possible to do much better. Things that help with this:

Email

I use a self-hosted Mautic instance to manage my mailing list, although I don’t necessarily recommend this to anyone else.

I started doing it so that I could do “drip” style email campaigns, like the one on Programming essays I think about a lot where you can get the essays emailed to you, one per week. I also started sending new subscribers a drip campaign of the best posts from the archives.

Editing emails in Mautic is kind of annoying, so I ended up building out some site infrastructure that let me generate the email HTML with Hugo (my static site generator) and then a small Python script that syncs them with Mautic. The other advantage of this is that if I update any of the posts in e.g. the “best-of” list, they automatically get included in the

Email-related things that I do recommend to others:

Sidenotes

My sidenotes are inspired by tufte-css. They look like this! They have a couple important ingredients.

First, a pure-CSS way of making the sidenotes lay themselves out in the right margin, without overlapping:

.sn-text {
    float: right;
    width: 200px;
    margin-right: -250px;
    clear: right;
    margin-top: 10px;
}

On mobile I can’t use margins, so instead, the sidenotes are hidden by default and the sidenote indicator symbol becomes clickable. This uses the hidden-checkbox trick described below. A different bit of CSS causes sidenotes to appear in a full-width box that interrupts the paragraph flow:

@media screen and (max-width: 1180px) {
    .sn-wrapper {
        display: none;
    }
    .sn-text {
        float: unset !important;
    }
    input:checked ~ .sn-wrapper {
        display: block;
        float: left;
        clear: both;
        width: 100%;
        margin: 10px 0;
        padding-left: 37px;
    }
    sup.sidenote label {
        color: #69e;
        color: var(--color-link);
        text-decoration: underline;
        cursor: pointer;
    }
}

To use the hidden-checkbox trick, I needed to put the sidenote body inside a <sup>. To undo all the styling this applied, I found the little-known all: initial CSS rule, which I apply to a style-root class. (I then have to re-apply all my base styles to style-root as well as html.)

.style-root {
    /* must go first so that all: initial
     * doesn't clobber the below */
    all: initial;
}
html, .style-root {
    background: #fff;
    background: var(--color-background);
    /* etc. */
}

Finally, a Hugo shortcode actually generates the necessary HTML using some super janky templating:

{{- /* Global sidenoteNum counter */ -}}
{{- if eq ($.Page.Scratch.Get "sidenoteNum") nil -}}
{{- $.Page.Scratch.Set "sidenoteNum" 0 -}}
{{- else -}}
{{- $.Page.Scratch.Set "sidenoteNum" (add ($.Page.Scratch.Get "sidenoteNum") 1) -}}
{{- end -}}
{{- /* Compute the symbol to use for the sidenote reference */ -}}
{{- $sidenoteNum := $.Page.Scratch.Get "sidenoteNum" -}}
{{- $sidenoteChars := slice "✻" "†" "‡" "§" "‖" "¶" -}}
{{- $char := index $sidenoteChars (mod $sidenoteNum (len $sidenoteChars)) -}}
{{- $numChars := div $sidenoteNum (len $sidenoteChars) | add 1 -}}
{{- $sidenoteSymbol := $char | strings.Repeat $numChars -}}
<sup class="sidenote">{{- /* */ -}}
  <label for="sn{{ $sidenoteNum }}">
    {{- $sidenoteSymbol -}}
  </label>{{- /* */ -}}
  <input
    id="sn{{ $sidenoteNum }}"
    type="checkbox"
    style="display: none"
  />{{- /* */ -}}
  <span class="style-root sn-wrapper">{{- /* */ -}}
    <span class="sn-text">{{- /* */ -}}
      <sup>{{- $sidenoteSymbol -}}</sup>
      {{ .Inner -}}
    </span>{{- /* */ -}}
  </span>{{- /* */ -}}
</sup>{{- /* */ -}}

(The {{- /* */ -}} are a Hugo templating hack to strip whitespace.)

Analytics

I wanted some basic analytics for my site that met the following criteria:

I searched around for a while before realizing that one of AWS’s many, many database offerings is really well-suited to this: Athena. Athena lets you query “databases” that are made up of many CSV files (like CloudFront logs) inside an S3 bucket. Even better, AWS also has a built-in tool for creating dashboards from various data sources including Athena, called Quicksight.

I realized this would let me do the whole thing without running any infrastructure myself: I could create an Athena “table” directly from the Cloudfront logs that AWS was already archiving for me, then create a Quicksight dashboard that queried the table, and I’d be in business.

AWS already has instructions for setting up Athena to query Cloudfront, so it ended up being about a 1-hour project to build a simple dashboard:

  1. Create the Athena table, per the instructions above.
  2. I created a derived “view” that included some extra columns that were useful for analytics, like is_bot and is_internal_referrer. (You can read the SQL if curious.)
  3. I moved my old Cloudfront logs to a different directory that wasn’t included in the Athena table, because I had 3+ years of logs and it was making my queries super slow.
  4. I set up Quicksight and built some charts.

I’m pretty happy with this setup—I have a dashboard for my common queries, and if I want to do any sort of complicated analysis, I can write ad hoc SQL to do it myself. Quicksight seemed buggy enough that I wouldn’t use it for anything serious, but it’s fine for my purposes and doesn’t cost anything for personal use. (For professional use, at Wave we use Periscope Data which is a better but pricier version of the same thing, and it’s been relatively great.)

One minor downside is that the dashboard takes several seconds to load due to the Athena queries being slow. I don’t mind this because I only look at it a couple times a week. If I cared a lot, there’s some stuff I could do with partitioning to make it faster at the expense of adding another moving part (a Lambda function to rename the Cloudfront log dumps).

Dark mode

There’s this cool thing called CSS variables that lets you define strings that represent colors. You can combine this with the prefers-color-scheme: dark media query to build a [DRY][] dark mode:

html {
    --color-background: #fff;
}
@media screen and (prefers-color-scheme: dark) {
    html {
        --color-background: #222;
    }
}
html, .style-root {
    background: #fff; /* backwards compat */
    background: var(--color-background);
}

The only other dark mode trick I have is a couple ways of making images less intrusive. By default, I don’t change anything, but with the right CSS class I can dim them (best for e.g. photos) or invert them (best for black and white):

@media screen and (prefers-color-scheme: dark) {
    .dark-invert {
        filter: invert(80%);
    }
    .dark-darken {
        filter: brightness(70%);
    }
}

Javascript-less buttons

There are two parts of this site that are hard to make work without Javascript.

The first is toggle buttons, like the “reply” buttons in comment forms that disclose a comment box. For these, I use the “hidden checkbox” CSS trick, where the button is a <label> for a hidden checkbox that stores the toggled state. I can then use the :checked pseudoclass to toggle CSS. HTML:

<div class="comment-reply">
  <input type="checkbox"
    id="reply-toggle-{{.id}}"
    class="reply-toggle"
  >
  <label for="reply-toggle-{{.id}}">
    <a class="reply-toggle-label ui-sans">reply</a>
  </label>
  <!-- comment form goes here -->
</div>

CSS:

.reply-toggle-label {
    line-height: 200%;
    cursor: pointer !important;
}
.reply-toggle {
    position: absolute;
    opacity: 0;
}
.reply-toggle:focus ~ label {
    outline: #666 auto 5px;
    outline-color: var(--color-secondary);
    outline-color: -webkit-focus-ring-color;
}
.reply-toggle:not(:checked) ~ .ui-form.reply {
    display: none;
}
.reply-toggle:not(:checked) ~ label > .reply-toggle-label::before {
    content: "\25b7  ";
}
.reply-toggle-label::before {
    content: "\25bd  ";
}

Javascript-less forms

The other tricky thing to do without Javascript is forms that don’t require navigating away from the page. I need this for mailing list signups, and for comments. It turns out that you can accomplish this by making the form target an iframe:

<form
  class="ui-form {{.classes}}"
  action="{{.action}}"
  target="{{.iframe_name}}"
  method="post"
  onsubmit="onSubmit(this)"
  >
<!-- Form contents go here -->
<iframe
  class="form-noscript"
  name="{{.iframe_name}}"
  onload="onIframeLoad(this)"></iframe>
</form>

The iframe can serve as the feedback indicator, redirecting to a page that says something like “your comment was submitted!” after processing the request.

Unfortunately, it doesn’t look super great. In particular, the iframe needs to be visible on the initial page load—without Javascript, there’s no way (that I know of) to dynamically show it only once the “submit” button is clicked.

For people who do have JS enabled, I’d like to do something a bit nicer. First, I hide the iframe (or rather, show it only if JS is disabled):

<noscript><style>
.ui-form iframe {
  display: block !important;
}
</style></noscript>

Then I use a couple of JS event handlers to apply CSS classes to the form depending on its state:

function onSubmit(form) {
  var iframeName = form.target;
  var iframe = document.getElementsByName(iframeName);
  form.classList.add('submitting');
  for (var i = 0; i < form.elements.length; i++) {
    form.elements[i].readOnly = true;
  }
}
function onIframeLoad(iframe) {
  // Fires when the form action is finished, but also on initial page load
  // (when we load the noscript page). We only want to change CSS if the
  // form was submitted.
  var form = document.querySelector('[target="' + iframe.name + '"]');
  // record that loading finished... but only if the form has been submitted
  if (form.classList.contains('submitting')) {
    form.classList.remove('submitting');
    form.classList.add('submitted');
  }
}

Stack

benkuhn.net is made with the Hugo static site generator with a custom theme. The footer art is by Eve Bigaj. The site is hosted by AWS S3/CloudFront and deployed via Github Actions. Analytics (backend only, no tracking) use AWS Athena and Quicksight (writeup). Comments are managed by Staticman hosted on Digital Ocean; mailing list signups go through AWS Lambda + Mautic.