How I Built a Dark Mode PWA without JS Libraries in 24 Hours
Tags: javascript, css, html, webdev
Published: 2020-01-04 15:57:40

---
title: How I Built a Dark Mode PWA without JS Libraries in 24 Hours
published: true
description: I decided to give my Hacker News reading experience a facelift.
tags: #js #css #html #webdev
---

## Motivation

I decided to give my Hacker News reading experience a facelift.

First and foremost, I wanted Dark Mode!

Second, I wanted to be able to "install" it on my iPhone's homescreen, so that it runs in its own process, and not in Safari. (Dev.to does this natively, kudos!)

I also wanted to build a project over break that would let me explore new web standards. I wanted to commit to using the latest tools of the native web platform, so I wouldn't use any JS libraries or create a build process. I also wouldn't worry about any browsers other than the ones I use every day -- latest Safari and Chromium.

Before I started, I also got the idea to make it a little more functional for myself, so that it loads to the top comment along with the headline.

Finally, I wanted to timebox it to 24 hours.

## Step #1: Loading Data

This was the easy part. The Hacker News API has an endpoint that provides JSON data of the stories. No authorization, no setup, just load the data.

Since I wasn't limited by browser support, I could safely use `fetch`, Promises, and `async`/`await`:

```js
const storyIDs = await fetch(`https://hacker-news.firebaseio.com/v0/topstories.json`).then(res => res.json())

const stories = await Promise.all(storyIDs.slice(0, 25).map(id => fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then(res => res.json())))
```

## Step #2: Templating and Dynamic Data

Each of the loaded stories would be rendered as an instance of a web component.

There are basically 3 types of data to consider when you use a web component:

* Named slots
* Custom properties
* Custom attributes

I ended up not having a need for custom attributes.

Let's start by looking at the template for a `top-story` element:

```html
  <template>
	<article class="top-story">
	  <span class="top-story-submitter">
	    <slot name="by"></slot>
	  </span>
	  <div class="top-story-content">
	    <a class="top-story-main" href="">
	      <h3 class="top-story-headline">
	        <slot name="title"></slot>
	      </h3>
	    </a>
	    <slot name="top-comment"></slot>
	  </div>
	</article>
  </template>
```

I'm using named slots where I want the dynamic content to go. This will be on the Shadow DOM side.

Anything in the Light DOM side with a matching `slot` attribute will be injected into the rendered template.

So for the dynamic data, I needed to convert each JSON data property received from the API into an HTML element with a `slot` attribute. I'm adding the JSON data to the web component as custom properties, then letting setting those properties trigger the creation of the elements with a `slot` attribute.

```js
  stories.forEach(story => {
    if (story) { // can be null
      const element = window.document.createElement('top-story')
      window.document.body.append(element)
      Object.assign(element, story)
    }
  })
```

`Object.assign` here is setting these directly on the element, so we can set those up to be custom properties that react to changes.

In the web component, I have a helper function to do the property conversion to slots, and I have a setter for each of the properties:

```js
window.customElements.define('top-story', class extends HTMLElement {
  constructor() {
    super()
  }
  setSlot(slot, value) {
    if (!this.querySelector(`[slot="${slot}"]`)) {
      const element = window.document.createElement('data')
      element.setAttribute('slot', slot)
      this.append(element)
    }
    this.querySelector(`[slot="${slot}"]`).innerHTML = value
  }
  set text(value) {
    this.setSlot('text', value)
  }
  ...
}
```

Now, if I change the data on the component, the slot will also update on the Light DOM side, which will update in place in the rendered Shadow DOM.

I can also use the setters to do other kinds of work. I want to embed another web component for the Top Comment inside this one, so I won't use my `setSlot` helper function. Instead, in the setter, I set up that component the same way I set up this one. This is also where I updated the `href` attributes on the links.

## Step #3: Code Splitting / Imports

Typically I use webpack for converting my projects to ES5 and concatenating into a single JS file.

Here I'm using native JS imports to add the split-up files. Add that to the fact that the base markup is in its own web component, and my HTML file ends up being pretty light:

```html
  <body>
    <app-screen></app-screen>
    <link rel="stylesheet" href="./styles.css">
    <script type="module">
      import './imports/fetcher.js'
      import './imports/AppScreenTemplate.js'
      import './imports/AppScreen.js'
      import './imports/TopCommentTemplate.js'
      import './imports/TopComment.js'
      import './imports/TopStoryTemplate.js'
      import './imports/TopStory.js'
    </script>
  </body>
```

## Step #4: Dark Mode

Although I always use Dark Mode, I wanted to use the native CSS media query that detects Dark Mode in the system settings, in case someone else was used to Light Mode instead:

```css
  @media (prefers-color-scheme: dark) {
    body {
      background: black;
      color: white;
    }
  }
```

## Step #5: PWA Installation

One of the most important aspects of all this was to make Hacker News run like a native app, in its own window and not in Safari. That way my scroll state would be preserved.

This is actually pretty simple for iOS:

```html
  <meta name="apple-mobile-web-app-capable" content="yes" />
 ```

To make this more compliant with other browsers, including Chromium Edge, which I have been using, I also added a manifest.json file:

```json
{
  "name": "Hacker News PWA",
  "short_name": "HN",
  "theme_color": "#CD00D8",
  "background_color": "#000000",
  "display": "standalone",
  "orientation": "portrait",
  "scope": "/",
  "start_url": "/",
  "icons": [{
    "src": "/icons/icon-512x512.png",
    "type" : "image/png",
    "sizes": "512x512"
  }]
}
```

## Challenge #1: Dates!

I ended up removing all dates from the project for now. I'm used to using a library such as moment.js or date-fns, and the native functions would sometimes show undefined or have other problems! I think for the final product, if I continue with it, I will pull in one of those libraries.


## Challenge #2: Time Constraints

I had planned on having the comments (and possibly even the story if iframe embed is supported) show up in a modal drawer that overlays the rest of the content. This might still happen, but it's outside the 24-hour timebox.

It also isn't quite a full-fledged PWA with service workers. I need to do some work on automatically refreshing content.

## Conclusion

I had a great time working on this, and I have started using it whenever I want to check Hacker News. You might enjoy it too.

Install it as an "Add to Homescreen" app from Safari:

http://hn-pwa-1.firebaseapp.com/

Contribute:

https://github.com/michaelcpuckett/hn-pwa-1

Final Result:

![Final Result](https://thepracticaldev.s3.amazonaws.com/i/817h5by6ls99wppwkzlk.png)