Skip to content
Ephemeral is a Progressive Web App for writing down words and their translations, as you encounter them.
Elm CSS TypeScript HTML JavaScript
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Type Name Latest commit message Commit time
Failed to load latest commit information.


Ephemeral is a progressive web app for writing down words and their translations, as you encounter them.

The live version is at

I made this originally when I immigrated to Finland and wanted a way to connect words with events and the world around me. I make this app in my free time.

The app works offline and all data is stored locally to your device.

The map screen of the application, with a marker on Helsinki.

Table of Contents

  1. Features
  2. Principles
  3. Development
  4. Production
  5. List of Technologies Used
  6. Architecture
  7. Contributing
  8. Code of Conduct
  9. License


  • Allows you to capture notes, time, and (optionally) the location
  • Works offline
  • Is resilient to errors
  • You can install it / add to home screen on most devices (including smartphones, desktops etc.)
  • You can always export and import data, as it's stored locally


The following principles guide the project. Each feature or change should be evaluated against them.

Data ownership

The application should avoid storing data in a remote location. The user notes (including time and location) should stay on the local device. It is up to the user to export them and share them however they want.

Note that this has implications for the architecture; many server-side solutions are not possible. That fact should be communicated to the user.


People use the web in different ways, and we should accommodate them.

The Web Content Accessibility Guidelines 2.1 (WCAG 2.1) cover a number of criteria to consider when putting things online. We should strive for level AA, or even AAA.

In practice, this means evaluating the markup that we put on the page, validating our assumptions about interactions, and ensuring that states are communicated correctly. Where such choices are made, we should document the reasoning and share references.


Not everyone has expensive, fast devices. In fact, as a trend, it appears that computing has gotten cheaper, not faster. A median device can still run slow if overloaded with Javascript. We should aim to deliver the experience in the amount of JS needed, and no more.

This has implications for the choice of technology, feature set and testing. For example, Elm allows us to have very small and perfomant bundles. Similarly, using Web Components and ports for more specialized APIs (maps, storage persistence) allow us to use tested implementations, without rolling our own potentially heavy and unreliable ones.

Don't give up on the user

An application that works locally can fail in many ways. We should inform the user why things failed, whether it is expected, and whether they can do anything about it (even if it means trying again later!).

For example, Geolocation can fail, data can get corrupted, the user might change the contents, or a service worker might be waiting to update. We should be honest about those possibilities, and architect the code in a way that will prompt us to communicate these to the user.

Document why

This is partially covered by the above. Where code is concerned, we should strive to document why a certain decision was made.

Was a certain CSS order needed to progressively enhance features? Did we elect a specific markup pattern to expose features to Assistive Technologies? Were there compromises or assumptions in any of them? These are the kind of things we should document.


To start, you will need to install Node.js. Node is a runtime for Javascript that works outside of the browser. Node comes with npm, which allows us to install packages. Both of them are used to set up development tooling.

Then, in a terminal:

npm ci
npm run dev

This will start a local server at http://localhost:8080. You can visit that page to see the application running. It will reload automatically as you make changes.

If you want more information on how exactly the application files and assets are built, check out the docs/ file.


For production, we have to build the application and deploy it to a public location.

Building the application is done with:

npm run build

This places the built files under dist/

The deployment is handled automatically through the Now for GitHub integration. It is not something that normally you would need to do if making a Pull Request.

That said, if you want to run the app in production mode (which applies some optimisations), you can run:

npm run prod

This will start a server at http://localhost:5000.

Finally, if you need the closest to production parity, which includes routes and cache headers in the server response, you can use now dev via:

npm install -g now
now dev

This can be useful if you want to debug the full server round-trips, as well as the Service Worker caching.

List of Technologies Used

If you are interested in contributing, you will see many of the following terms and libraries. We introduce them here to establish a common starting point.

Elm is a delightful language for reliable web apps. We use it for the core UI and data architecture. Elm enables us to express our interface as a function of data. It also helps us handle error cases in the code, in a way that is resilient and can be expressed to the user in an actionable way.

A Progressive Web App (PWA) works offline via a Service Worker. A Service Worker is a script that runs in the browser detached from the website, and caches assets and other scripts. A PWA can be installed locally, on most devices. Here is an intro to PWAs, by Google.

Service Workers are a rather low-level API. To make them more declarative, and to manage the asset invalidation when they change, we use Workbox.

For storage, Ephemeral uses IndexedDB, which is a local, persisted database in the browser. IndexedDB is built-in to browsers, and exposes a number of low-level interfaces. To make that more manageable, we use idb, which is a library that wraps IndexedDB in promises, and tries to expose errors more consistently.

For maps, we use Leaflet. Leaflet is a well-established JS library for rendering maps and features on them.

In order to integrate Elm with more complex UI, like the Leaflet map, we use the set of technologies called Web Components. They allow us to wrap UI in a way that Elm can render declaratively, but that keep internal details and handlers.

For parts where we would interface with Javascript, we use TypeScript, a typed superset of Javascript. It offers a type system aimed to model Javascript's behaviour. Keep in mind, the type system is different to Elm's, but nonetheless it helps us type the boundaries between parts of the application.


The Index

The application has the entry point index-dev.ts or index-prod.ts, depending on the environment.

The index is responsible for importing the parts of the application. They are :

  • The Core Elm Application and Ports (imported statically)
  • The Leaflet Web Component (imported dynamically)
  • Information UI components (imported dynamically)

The Core Elm Application and Ports

The core Elm application is initialised from src/client.ts.

It does the following:

  • Sets up the flags (some initial configuration) for the Elm application
  • Attaches the Elm application to the DOM
  • Sets up listeners for each port; ports are the way in which Elm communicates to JS

The Elm Application Internals

NOTE: Before you read this section, I recommend going through the official Elm guide. It establishes a lot of the concepts used below :)

The Elm application entry point is src/Main.elm. Main imports each Page/*.elm module, and handles the routing to each one. Each Page module might have its own internal model, update, view and subscriptions. Main ensures that each Page's functions are connected correctly, and that they get the data they need.

Some pages might need data from a top-level shared model, such as the list of Entries that the user has stored. We tentatively call this Context, but other names and ways to do this, such as SharedState, exist as well.

If you want to go through the application, my recommendation would be to start the Page module you are interested in, and then work up or down from there. You could also start at Main, but your mileage may vary.

Apart from Page modules, the rest is centered around data types. For example, the Entry.elm module defines the Entry type. An Entry has some internal structure, and some exposed functions to work with it. It also establishes some functions to encode into and decode from JSON. This is useful when we need to communicate with JS through ports, or when we write to a file.


Ports are used to communicate with JS. There are a few things that we handle with port modules:

  • Saving and fetching things from the IndexedDB Store
  • Setting user preferences for Dark Mode
  • Geolocation
  • Handling notifications of Service Worker updates and Installation

These are usually in paired TypeScript and Elm modules. Thus, we have Store.elm/Store.ts, DarkMode.elm/DarkMode.ts and so on.

An important decision here, is that each module has only two ports, fromElm and toElm. This enforces all the data to pass through a common interface, and makes it clear that there are no request/response relationships between Elm and JS. Something in JS/TS could fail to respond altogether, and we should be prepared for it.

For each pair, then:

  • An Elm module, say Store.elm, establishes functions to send messages to JS (FromElm). It also establishes messages it can process from JS (ToElm).
  • Each Typescript module, say Store.ts, then has a handleSubMessage function. It can do arbitrary things with the message, such as going to the database, or just logging to the console. It gets passed a sendToElm function, that it can use to send a ToElm message.
  • On the Elm module, any Page (or Main) that wants to use messages from JS, declares it in the subscriptions function.

This approach has a little bit more typing, and encoding/decoding. However, given the Principles outlined above, creating a more strict boundary seems important for a resilient application.

The Leaflet Web Component

The Leaflet Web Component is one of the more interesting bits of interop here. Leaflet is a fantastic library, and it has a heavy OOP and imperative API.

To add a marker to leaflet, you:

  • Create a layer
  • Add the feature to the layer
  • Add the layer to the map, if not already there
  • Bind the popup content of the feature, if any

Alongside that, Leaflet wants undisturbed access to its section of the DOM. This clashes with Elm though, which in our case controls the entire body, and would try to diff things. (This is all working as intended btw, I think it makes sense).

We could try to do all this with ports. In fact, I tried it in the past, but it was messy. A different way I wanted to take was to use a Web Component.

In an ideal world, I'd like to render the following from Elm, and get a Leaflet map:

<leaflet-map defaultLatitude="23.93" defaultLongitude="60.16">
  <leaflet-marker latitude="23.93" longitude="60.16">
    <p>Hello, I have HTML content in a popup!</p>

Web components allow us to get exactly that. We tie the imperative behaviour to changes in the DOM (a process manual, but workable). Then we add the real Leaflet map to an "internal" DOM, or Shadow DOM. As far as Elm is concerned it is only diffing the top-level <leaflet-map> and <leaflet-marker>, unaware of the "internal" DOM that Leaflet is happy to make changes to.

For me, this pairing of Elm (which will respect custom elements) and Web Components is great, especially when it comes to rendering UI! You could also use Web Components for more JS/Elm communication through Custom Events, but I find ports a cleaner boundary for that.

If you want to see the small mess of setting this up, check out src/leaflet/leaflet-map.ts.


If you are interested in contributing, please consult for how to do so. That document covers topics such as opening issues, creating PRs, running tests, as well as the principles and code of conduct.

Code of Conduct

This project has a Code of Conduct. By contributing you agree to abide by it.


Mozilla Public License Version 2.0

You can’t perform that action at this time.