Welcome to my website! I’m Jonathan Y. Chan (jyc, jonathanyc, 陳樂恩, or 은총), a 🐩 Yeti fan, 🇺🇸 American, and 🐻 Californian, living in 🌁 San Francisco: the most beautiful city in the greatest country in the world. My mom is from Korea and my dad was from Hong Kong. I am a Christian. I’ve worked on:

a failed startup (co-founded, YC W23) that let you write Excel VLOOKUPs on billions of rows;

Parlan was a spreadsheet with an interface and formula language that looked just like Excel. Under the hood, it compiled formulas to SQL then evaluated them like Spark RDDs. Alas, a former manager’s prophecy about why startups fail proved prescient…

3D maps at Apple, where I did the math and encoding for the Arctic and Antarctic;

I also helped out with things like trees, road markings, paths, and lines of latitude!

various tasks at Figma, which had 24 engineers when I joined;

… including copy-paste, a high-fidelity PDF exporter, text layout, scene graph code(gen), and putting fig-foot in your .fig files—while deleting more code than I added!

Blog

Elixir map iteration order is very undefined

The iteration order for Elixir maps is not just “undefined” in the sense that there is some order at runtime which you don’t know. Different functions that take maps can also iterate over the map in different orders!

Lists have the iteration order you’d expect:

range = 1..32
Enum.map(range, fn a -> a end)
Enum.zip_with(range, range, fn a, b -> {a, b} end)
# [1, 2, 3, ...]
# [{1, 1}, {2, 2}, {3, 3}, ...]

… and so do maps with 32 or fewer entries:

range = 1..32
map = Enum.map(range, &{&1, true}) |> Enum.into(%{})
IO.inspect(Enum.map(map, fn {k, _v} -> k end))
IO.inspect(Enum.zip_with(range, map, fn _, {k, _v} -> k end))
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
#   22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
#   22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]

… but add one entry to a map and the pattern breaks:

range = 1..33
# ...
# [4, 25, 8, 1, 23, 10, 7, 9, 11, 12, 28, 24, 13, 3, 18, 29, 26, 22, 19, 2, 33,
#   21, 32, 20, 17, 30, 14, 5, 6, 27, 16, 31, 15]
# [15, 31, 16, 27, 6, 5, 14, 30, 17, 20, 32, 21, 33, 2, 19, 22, 26, 29, 18, 3,
#   13, 24, 28, 12, 11, 9, 7, 10, 23, 1, 8, 25, 4]

Enum.zip_with happens to enumerate over the entries of a map in opposite order from Enum.map!

I think it’s especially funny that this behavior only manifests for maps with more than 32 elements! It reminds me of this plotline (no spoilers) from Cixin Liu’s mind-blowing Remembrance of Earth’s Past trilogy:

 “These high-energy particle accelerators raised the amount of energy available for colliding particles by an order of magnitude, to a level never before achieved by the human race. Yet, with the new equipment, the same particles, the same energy levels, and the same experimental parameters would yield different results. Not only the results would vary if different accelerators were used, but even with the same accelerator, experiments performed at different times would give different results. Physicists panicked. …”

 “What does this mean? Wang asked. …

 “It means that the laws of physics are not invariant across time and space.”

On a less dramatic note, it reminds me of the Borwein integrals discovered by David Borwein and Jonathan Borwein in 2001:

$$ \int_0^\infty \frac{\sin(x)}{x} dx = \frac{\pi}{2} $$ $$ \int_0^\infty \frac{\sin(x)}{x} \frac{\sin(x/3)}{x/3} dx = \frac{\pi}{2} $$ $$ \int_0^\infty \frac{\sin(x)}{x} \frac{\sin(x/3)}{x/3} \cdots \frac{\sin(x/13)}{x/13} dx = \frac{\pi}{2} $$ $$ \int_0^\infty \frac{\sin(x)}{x} \frac{\sin(x/3)}{x/3} \cdots \frac{\sin(x/15)}{x/15} dx = \frac{\pi}{2} - 2.32 \times 10^{-11} $$

It’s interesting to think about the different kinds of behavior which you can’t know ahead-of-time. Suppose I roll some dice inside of a closed box.

  1. Non-deterministic but fixed. When I open the box, I see the dice have some value which I couldn’t predict, but which is the same regardless of how I open the box.
  2. Not fixed. After I’ve opened the box, every time I look at the dice, their values have changed.
  3. Depending on how I open the box, the dice have different values.

Unicode codepoint ranges for emoji

I’d assumed that emoji were all organized into a contiguous Unicode codepoint range, but this is very much not the case! There are more than a thousand different ranges containing emoji. The Unicode consortium makes the complete list available as a file, emoji-data.txt.

Here are a few lines:

25FB..25FE    ; Emoji                # E0.6   [4] (◻️..◾)    white medium square..black medium-small square
2600..2601    ; Emoji                # E0.6   [2] (☀️..☁️)    sun..cloud
2602..2603    ; Emoji                # E0.7   [2] (☂️..☃️)    umbrella..snowman
2604          ; Emoji                # E1.0   [1] (☄️)       comet

I wanted to convert this list into the form U+25FB-25FE,U+2600-2601,... for use with the kitty terminal’s symbol_map configuration option.

I wrote some shell to convert it into that format:

curl https://www.unicode.org/Public/UCD/latest/ucd/emoji/emoji-data.txt \
  | grep -v '^#' \
  | sed -e 's/;.*//' -e 's/[[:space:]]//g' -e '/^$/d' -e 's/\.\./-/' -e 's/^/U+/' \
  | tr '\n' ',' \
  | sed 's/,$//'

Some pretty big caveats:

  • emoji-data.txt also contains ASCII codepoints like # (0x23), * (0x2a), and 0-9 (0x30-0x39)! Depending on your usecase you might want to remove these.
  • One rendered emoji can be composed from multiple codepoints. For example, the emoji 🏋️‍♂️ (man lifting weights) is composed from three codepoints: person lifting weights + zero width joiner + male sign. All three codepoints are listed separately in emoji-data.txt.

One Paragraph Reviews, Vol. I

Going to try and see if this format helps me get through the backlog of reviews I’ve been meaning to write. The schema I’ll try is: (1) why it’s interesting (2) the most interesting insight.

  • 3D Gaussian Splatting for Real-Time Radiance Field Rendering by Kerbl, Kopanas, Leimkühler, and Drettakis (2023). The authors reconstruct 3D scenes from 2D images and render much faster than before (≥ 100fps) by representing them as clouds of blurry balls. No neural networks–backpropagation is used to position the blurry balls (really “anisotropic Gaussians”; anisotropic just means they are rotated/skewed); errors are propagated all the way back from image-space pixels to world-space Guassians! Another interesting non-neural network use of backprop is Constrain by Prof. Andrew Myers at Cornell, which uses backprop for constraint-based 2D graphics.
  • John Calvin’s Anxiety by William J. Bouwsma (1984). Calvinism is mostly known to others1 for the doctrine of predestination; essentially the idea that God alone chooses who to save: not even the saved get a choice! This sounds fatalistic, so it’s interesting that Bouwsma, who was a history professor at Berkeley, observes that: “Anxiety is a motif that beats through almost everything Calvin wrote.” Bouwsma thinks that Calvin’s anxiety about “fragile [physical] world” is connected to Calvin’s conception of a “constantly active” God so powerful as to appear “arbitrary.”
  • Compiler and Runtime Support for Continuation Marks by Flatt and Dybvig (2020). Continuation marks are similar to dynamically-scoped variables, like UNIX shell environment variables. In languages like Scheme with first-class continuations, they can be used to efficiently implement features like exceptions, which in e.g. Java you could never implement outside of the compiler. The paper is mostly about the efficient implementation of continuation marks in Chez Scheme, but it has a good overview of continuations and continuation marks.
  • The Remains of the Day by Kazuo Ishiguro (1989). An English butler reminisces as he leaves on a road trip from Darlington Hall, the English aristocratic mansion in which he’s worked his whole life. You come to find that he is not being entirely honest with himself. But Ishiguro skill as a writer allows emotion to pours through the pages in spite of this. The best book I’ve read in 2024 so far: a true masterpiece2 of show-don’t-tell.
  • Introduction to H.264 Advanced Video Coding by Chen, Kao, and Lin (2006). A nice introduction to H.264, the preeminent video codec today: it’s probably built into the hardware of the computer you’re using to read this! Frames are broken up into macroblocks; each macroblock is encoded as a prediction plus a residual. There are three prediction modes: P(redicted)-type macroblocks are based on a macroblock from a previous frame, B(idirectional)-type macroblocks are based on a weighted average of macroblocks from multiple frames, and I(ntracoded)-type macroblocks are based on neighboring macroblocks in the same frame. P and B-type macroblocks have motion vectors that describe the offset from which to take their reference frame. For example, if a ball is rolling around, it could ideally be encoded as one I-type macroblock then a series of P-type macroblocks with motion vectors and small residuals. The residual is compressed using the discrete cosine transform, dropping higher-frequency signals (e.g. fine textures), similar to JPEG. It was interesting to learn that many more bits are used for luminance/Y (256) than chroma (64 for blue/U, 64 for red/V).

… and that’s it for now!

1

The denomination of which I am a member, the Presbyterian Church (USA), considers itself to belong to the Reformed/Calvinist tradition.

2

Mr. Ishiguro won the Nobel Prize in Literature in 2017, so unfortunately I can’t say I read him before he was cool.

Imagine you're ChatGPT...

You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture.

— OpenAI’s system prompt for ChatGPT

Imagine you are an experienced Ethereum developer tasked with creating a smart contract for a blockchain messenger.

— a ChatGPT prompt found on the web

Peter Watts predicted LLM prompts in Blindsight (2006) and Echopraxia (2014):

Imagine you are Siri Keeton:

You wake in an agony of resurrection, gasping after a record-shattering bout of sleep apnea spanning one hundred forty days.

 “Something’s coming,” she said at last. “Maybe not Siri.”
 “Why do you say that?”
 “It just sounds wrong the way it talks there are these tics in the speech pattern it keeps saying Imagine you’re this and Imagine you’re that and it sounds so recursive sometimes it sounds like it’s trying to run some kind of model…”
Imagine you’re Siri Keeton, he remembered. And gleaned from a later excerpt of the same signal: Imagine you’re a machine.
 “It’s a literary affectation. He’s trying to be poetic. Putting yourself in the character’s head, that kind of thing.”

Corporate Processing Service scam

Received this official-looking document in the mail by virtue of having my address associated with my failed startup. If you look at the fine print you’ll notice it’s not actually from the government. It’s from a scam company called “Corporate Processing Service” that is generously offering to file a form for you for \$243.

The state only charges you \$25 and has an online form. See “Misleading Statement of Information Solicitations” on the California Secretary of State’s website.

Adventures printing a PDF

Just tried to print out a PDF and it’s been an adventure!

  • Preview and Chrome don’t print any math notation, including in figures
  • Zotero prints it all out, but blurry
  • Firefox prints out nothing but blurry complete figures (!)

All of them render it properly on my computer though.

The PDF is “Compiler and Runtime Support for Continuation Marks” (Flatt & Dybvig, 2020).

If a function is only called from a single place, consider inlining it. – John Carmack

From John Carmack on Inlined Code on Jonathan Blow’s blog.

A personal link shortener using mostly S3

I was excited to learn that S3 has natively supported redirects since 2012. That meant that I could use the power of the ✨ cloud ✨ to get my own link shortener for “free”–thanks to AWS’s Free Tier–without having to muck around with databases, web servers, or serverless-cloud-lambda-edge functions. Each link is just a zero-byte file in an bucket, which S3 itself magically turns into a redirect.

I wrote a script to shorten links from the terminal but you could easily make a GUI. All it does is run aws s3api put-object:

$ publish-link 'https://example.com/long/url/here/'
https://.../zZzZz

The downsides are that you need to bring your own:

  • domain name and
  • AWS account

But think of the advantages!

  • even shorter links, because it’s just you
  • ???
  • it’s fun

I threw in CloudFront and Route 53 just so I could get HTTPS links. Route 53 is the only thing that doesn’t fit within the free tier AFAIK–it costs $0.50/mo. It should be pretty straightforward to delete Route 53 from the configuration below though.

The setup process is easy, assuming you have a domain name and AWS account lying around (!), but there’s a bit of waiting involved. You have to wait around 15 minutes for each of the following:

  • DNS records to propagate, after replacing your domain registrar’s nameservers with Route 53’s
  • AWS to issue an SSL certificate
  • AWS to create a CloudFront distribution

Use that time to walk your dog!

I ended using 5-digit base-58 keys1 because I figured that if I live for 50 more years I definitely won’t shorten more than one link per second:

$$ \frac{\log 86400 \times 365 \times 50}{\log 58} \approx 5.2 $$

I guess if I were really worried about the Birthday Paradox I could square the total number of links, which doubles the keylength to $10.4$. If I pick $\sqrt{N}$ keys out of $N$ possibilities, the probability of at least one collision is around 50% (for the Birthday Paradox the actual number is $23 \approx 19.104 \approx \sqrt{365}$). But I’m fine with the odds as-is.

Another consideration is that short keys make it easier for someone else to iterate through all of them to find what links you’ve shortened. Either don’t shorten secret links, or use a longer keylen. Or set up an AWS WAF rule to rate-limit requests 😱.

The script I use to shorten links is called publish-link. On macOS pbcopy copies standard input to my clipboard. On Linux I’d use xclip -sel clip and on Windows I’d use clip.exe.

#!/bin/zsh

set -euo pipefail

# Replace $bucket with your own domain.
bucket="example.com"
domain="$bucket"
keylen=5

if [ $# -lt 1 ]; then
  echo "error: path required" >&2
  exit 1
fi

while true; do
  base58_alphabet="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
  key=""
  for i in {1..$keylen}; do
    index=$(($RANDOM % ${#base58_alphabet}))
    key+=${base58_alphabet:$index:1}
  done
  if ! aws s3api head-object --bucket "$bucket" --key "$key" >/dev/null 2>/dev/null; then
    break
  fi
  echo "$key is already in use; happy birthday! retrying.." >&2
done

aws s3api put-object --website-redirect-location "$1" --bucket "$bucket" --key "$key" >/dev/null

url="https://$domain/$key"
echo -n "$url" | pbcopy
echo "$url"

The meat is in this Terraform file, which I place in main.tf. Caveat emptor, I’m not an expert at cloud infrastructure. I based it off of a configuration I’ve been using to host static files for a few years now that works just fine. If something looks funky it’s probably on me, not you.

# I don't think you actually need versions this new, but this is what I tested with.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.34"
    }
  }
  required_version = ">= 1.7.2"
}

# Replace link_domain with your own domain.

locals {
  link_domain   = "example.com"
}

# Replace region with the region you prefer.

provider "aws" {
  profile = "default"
  region  = "us-west-2"
}

# CloudFront needs ACM certificates to be from us-east-1.

provider "aws" {
  alias  = "us-east-1"
  region = "us-east-1"
}

# Create an S3 bucket with public reads.

resource "aws_s3_bucket" "link" {
  bucket = local.link_domain
}

resource "aws_s3_bucket_policy" "link" {
  bucket = aws_s3_bucket.link.id
  policy = data.aws_iam_policy_document.link.json
}

data "aws_iam_policy_document" "link" {
  statement {
    principals {
      type        = "AWS"
      identifiers = ["*"]
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${aws_s3_bucket.link.arn}/*",
    ]
  }
}

# Set up a website endpoint, which is what implements redirects.

resource "aws_s3_bucket_website_configuration" "link" {
  bucket = local.link_domain
  # These aren't actually used, we just need a website endpoint so we get
  # redirects.
  index_document {
    suffix = "index.html"
  }
  error_document {
    key = "error.html"
  }
}

# Set up Route 53. This is very much optional, I just wanted to have AWS manage
# the SSL certificate for me.

resource "aws_route53_zone" "link" {
  name = local.link_domain
}

resource "aws_acm_certificate" "link" {
  domain_name       = local.link_domain
  validation_method = "DNS"
  provider          = aws.us-east-1
}

resource "aws_route53_record" "link_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.link.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = aws_route53_zone.link.zone_id
}

# Request an SSL certificate from AWS. This can take a while.

resource "aws_acm_certificate_validation" "link" {
  certificate_arn         = aws_acm_certificate.link.arn
  validation_record_fqdns = [for record in aws_route53_record.link_cert_validation : record.fqdn]
  provider                = aws.us-east-1
}

# Set up CloudFront. S3 doesn't support HTTPS so we put CloudFront in front.

resource "aws_cloudfront_distribution" "link" {
  origin {
    origin_id   = local.link_domain
    domain_name = aws_s3_bucket_website_configuration.link.website_endpoint
    custom_origin_config {
      http_port              = 80
      origin_protocol_policy = "http-only"
      # S3 only supports HTTP, so these are irrelevant...
      https_port           = 443
      origin_ssl_protocols = ["TLSv1.2"]
    }
  }
  aliases = [local.link_domain]
  enabled = true
  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.link_domain
    forwarded_values {
      query_string = true
      cookies {
        forward = "none"
      }
    }
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 31536000 # 1 year
    max_ttl                = 31536000 # 1 year
  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }
  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate_validation.link.certificate_arn
    ssl_support_method  = "sni-only"
  }
  price_class = "PriceClass_100"
}

# Point Route 53 to CloudFront.

resource "aws_route53_record" "link" {
  zone_id = aws_route53_zone.link.zone_id
  name    = local.link_domain
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.link.domain_name
    zone_id                = aws_cloudfront_distribution.link.hosted_zone_id
    evaluate_target_health = false
  }
}

To set things up, run:

$ terraform init
$ terraform apply

When you see:

aws_acm_certificate_validation.link: Still creating...

… go to “Hosted zones” in the Route 53 console–make sure to select the right AWS region–then take the nameservers from the NS record and plug them into your domain registrar.

Now you’re all set to create your own short links!

$ publish-link 'https://www.jonathanychan.com/blog/link-shortener/'
https://example.com/zZzZz
1

I used base-58 because I use shortened links when hand-writing notes. Base58 omits characters that look similar, so e.g. 0 and O are absent and only o is present.

Poor structuralists

Given their disdain for the social sciences, certain Silicon Valley venture capitalists have an ironic affinity to unwittingly describing things in structuralist terms. And they do a poor job at it: the overloaded “bottom-up” vs. “top-down” metaphor for everything from growth to ideation to what kind of salad they ate today is just word salad in comparison to Lévi-Strauss’s Culinary Triangle or Lacan’s graph of desire.

Lévi-Strauss’s Culinary Triangle

A common criticism of structuralism from those in the “hard sciences” is that the graphs and triangles are mostly for show. Arranging concepts in the shape of a triangle or a graph not only illustrates some intended structure but also implies additional structure. This isn’t a problem so long as the boundary between the two is clear. But sometimes the boundary isn’t clear, and then it can look like the author has just made a bad-faith attempt to dress up a platitude in obscurantist but prestigious symbolism: the payoff isn’t there.

Consider for example Lévi-Strauss’s canonical formula for myths:

$$ F_x(a) : F_y(b) \sim F_x(b) : F_{a^{-1}}(y) $$

That’s a lot of structure. Yet no one can deny that he put a lot of effort into making it pay off. And so its influence extends beyond anthropology: a mathematician made an attempt to provide a category-theoretic interpretation of the canonical formula.1 All this is possible because he put in the work to develop the theory consequent from the structure he introduced in many books and papers.

In contrast, some VCs engage in argument by confusion: they’ll write a long essay where they throw five different vaguely structural metaphors at you, ensuring that by the time you’ve started to wonder whether a metaphor makes sense you already have to grapple with a new one.

1

Morava, Jack. On the canonical formula of C. Levi-Strauss. arXiv:math/0306174

Opening macOS applications from the dock in the current space

In macOS, if you click on an application’s icon in the dock, it’ll either:

  1. open it, if it isn’t already running,
  2. gently bring it to the foreground if it has a window in the current space, or
  3. violently throw you to who knows where if the application has a window open in a different space that you forgot about.

The third behavior always drove me nuts, although I can see why you’d prefer it, e.g. if you associate particular applications with specific spaces. You can get around this by remembering to always right-click the icon and select “New Window” if the application supports this. But the unpredictability always annoyed me to no end.

I wrote a cursed Hammerspoon script today to get around this. It prevents macOS from throwing you to a different space and instead opens a new window in the current one. It only works for apps hardcoded in the newWindowCommands table; in a previous version I tried sending make new window by default, but sometimes that would cause macOS to act like I held down the mouse button on the dock icon, which is weird.

🚨 Warning: this code will run any time you click anywhere. It will add a delay, albeit a very small one, to how long it takes macOS to respond to mouse clicks.

I don’t actually recommend using this script unless you’re like me and the costs outweigh the benefits. I like to use different spaces for different work, which means that I’ll have e.g. Firefox open in two different spaces. I personally don’t notice the delay at all, but caveat emptor. If you comment in the block which only enables the new behavior when the Shift key is held down, the impact on your clicks should be even lower.

function initNewWindowShortcut()
  local fileNewWindow = {
    menu = "File",
    item = "New Window",
  }
  local newWindowCommands = {
    Safari = "make new document",
    TextEdit = "make new document",
    ["Google Chrome"] = "make new window",
    ["Microsoft Edge"] = "make new window",
    kitty = {
      menu = "Shell",
      item = "New OS Window",
    },
    Firefox = fileNewWindow,
    ["Firefox Nightly"] = fileNewWindow,
    Orion = fileNewWindow,
    ["Orion RC"] = fileNewWindow,
    Mail = {
      menu = "File",
      item = "New Viewer Window",
    },
    Things = {
      menu = "File",
      item = "New Things Window",
    },
  }

  local log = hs.logger.new("newWindow", "debug")

  local bundleIDCache = {}

  function handler(event)
    -- Comment this in to only do anything when Shift is held down.
    --local mkeys = hs.eventtap.checkKeyboardModifiers()
    --if not mkeys.shift then
    --  return false
    --end

    local element = hs.axuielement.systemElementAtPosition(hs.mouse.absolutePosition())
    if not element or element.AXRole ~= "AXDockItem" then
      return false
    end
    local title = element.AXTitle

    local infoPlistPath = element.AXURL.filePath .. "/Contents/Info.plist"
    local bundleID = bundleIDCache[infoPlistPath]
    if not bundleID then
      bundleID = hs.plist.read(infoPlistPath).CFBundleIdentifier
    end

    local script
    local command = newWindowCommands[title]
    if not command then
      return false
    end
    if type(command) == "table" then
      script = [[
  tell application "System Events" to tell process "]] .. title .. [["
    click menu item "]] .. command.item .. [[" of menu 1 of menu bar item "]] .. command.menu .. [[" of menu bar 1
    activate
  end tell
]]
    else
      -- type(command) ~= "table"
      script = [[
tell application "]] .. element.AXTitle .. [["
  ]] .. command .. "\n" .. [[
  activate
end tell
]]
    end

    -- This is slow, so do it after looking up the title in newWindowCommands.
    -- If the app is not already running, or has any windows open in the
    -- current space, let the mouse click pass through.
    local app = hs.application.find(bundleID, true, true)
    if not app or #app:allWindows() > 0 then
      return false
    end

    log.i(script)
    hs.osascript.applescript(script)

    -- This seems to be needed for Orion and Firefox, but not Chrome.
    app:activate()

    return true
  end

  local tap = hs.eventtap.new({ hs.eventtap.event.types.leftMouseDown }, function(event)
    -- Run `handler` in `pcall` because if any code in this event tap errors,
    -- clicks will stop working entirely!
    -- deleteOrError is:
    -- * ok: whether to delete the event
    -- * not ok: the error message caught by pcall
    local ok, deleteOrError = pcall(function()
      return handler(event)
    end)
    if not ok then
      log.e(deleteOrError)
      return false
    else
      return deleteOrError
    end
  end)

  tap:start()
  return tap
end
-- Save event tap so it doesn't get GC'ed.
newWindowTap = initNewWindowShortcut()

In the future, it’d be nice to make this work for all methods of activating an app, e.g. opening an app through Spotlight.

Fun

is the card of the week.

I'm computing the week number by dividing the number of days we are into the year by 7. This gives a different week number from ISO 8601. Suits are ordered diamonds, clubs, hearts, spades (like Big Two, unlike Poker) so that red and black alternate. On leap years there are 366 days in the year; the card for the 366th day is the white joker. Karl Palmen has proposed a different encoding.