Red Dot Tracker

facet

facet is a shared design language for a family of websites: design tokens, components and layouts in plain CSS and a little vanilla JavaScript, with no build step. Each site picks its own accent; everything else is the same.

Version 0.1.0 · MIT license

What is here

Install: from a CDN

Link the stylesheet and the script. Link facet.css first and cast/cast-layered.css after it (only needed if you use the mascots). Social icons need icons.css too. Pin a tag so the site does not move with the main branch.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/facet.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/cast/cast-layered.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/icons.css" />
<script type="module" src="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/js/facet.js"></script>

jsDelivr can be unreliable in mainland China, and the fonts load relative to facet.css. For sites aimed there, use the submodule below and serve the files with the site.

Install: as a git submodule

git submodule add https://github.com/h1994st/facet vendor/facet
git -C vendor/facet checkout v0.1.3

Serve vendor/facet/dist/ as static files with your site, then link it by a relative path.

<link rel="stylesheet" href="/vendor/facet/dist/facet.css" />
<link rel="stylesheet" href="/vendor/facet/dist/cast/cast-layered.css" />
<link rel="stylesheet" href="/vendor/facet/dist/icons.css" />
<script type="module" src="/vendor/facet/dist/js/facet.js"></script>

A minimal page

<html lang="en" data-accent="moss">
  <head>
    <link rel="stylesheet" href="…/dist/facet.css" />
    <script type="module" src="…/dist/js/facet.js"></script>
  </head>
  <body>
    <nav class="f-nav">
      <span class="f-nav__brand">Duna Feline</span>
      <a href="/" aria-current="page">Blog</a>
    </nav>
    <main class="f-page f-prose">
      <h1>Hairball</h1>
      <p>Why the red dot always moves.</p>
    </main>
  </body>
</html>

Behavior

facet's JavaScript is optional. Importing dist/js/facet.js initializes itself: once the page has parsed, each module takes over the elements that opt in with a data-f-* attribute. Calling init() again is safe, since elements already set up are skipped, and a module that fails is reported in the console without stopping the others.

Hook Module What it does
data-f-ga4 (on <html>), data-f-event, data-f-label analytics.js Optional Google Analytics 4: <html data-f-ga4="G-…"> loads gtag once and sends one config (one page view). Nothing loads when the browser sends Global Privacy Control. data-f-event="name" sends that event on click (with link_url for links and data-f-label as label); theme switches send theme_change. A consent banner is out of scope. For GA4 consent mode, define window.dataLayer and gtag and push gtag("consent", "default", …) in a classic <head> script before facet.js: analytics.js reuses an existing gtag. Do not also keep your own gtag("config", …) snippet, or every view is counted twice.
data-f-theme-toggle theme.js On a button: cycles dark, light and auto, saves the choice in localStorage["facet-theme"] and labels the button.
data-f-liquid liquid.js On a .f-card or a container of cards: a light that follows the mouse across each card. Mouse only.
data-f-scrollspy scrollspy.js On a .f-toc: marks the link of the section being read with aria-current="location".
data-f-nav-cover="#cover" nav.js On a .f-nav--over: keeps the nav clear over the cover and turns it to glass once the cover has scrolled away.
data-f-autoplay, data-f-autoplay-toggle autoplay.js On a muted looping video: plays it while it is on screen. A button with data-f-autoplay-toggle and aria-controls becomes its pause control (labels from data-f-pause and data-f-play).
data-f-story story.js On a .f-story: pins the stage and shows the image (img[data-step]) of the step being read.
data-f-panels, data-f-panel-toggle panels.js On a .f-panels: marks the settled panel, plays only its video and fills an empty nav.f-dots. A panel's data-f-panel-toggle button becomes its video's pause control.
data-f-deck, data-f-piece, data-f-slide, data-f-from, data-f-deck-prev, data-f-deck-next deck.js On a .f-deck: turns the slides into one stage whose pieces move between slides (the data format is below).
data-f-type typewriter.js A JSON array of phrases: types each one, holds it, clears it and types the next, looping. The element's own text is the still state, kept under reduced motion. Tune with data-f-type-speed and data-f-type-hold (ms). Put it in an aria-hidden wrapper next to the real value in visually hidden text.
data-f-variants, data-f-variant variants.js On a .f-segmented with data-variant buttons: switches every data-f-variant="group:value" inside the control's parent. The control and the non-default variants ship hidden, so without JavaScript the first variant shows and there are no dead buttons. A variant whose value has no button stays hidden, a control with no buttons is skipped and stays hidden, and variants added after init are not picked up (call init again).

Use one module

Each module can be imported on its own. It exports init(root = document), which returns a cleanup function that removes the listeners and elements it added. facet.js exports init too, plus cleanup, which undoes its own initialization.

import { init } from "…/dist/js/deck.js";

const stop = init(document.querySelector("#talk"));
// Later, before the deck leaves the page:
stop();

Avoid a theme flash

facet.js runs after first paint, so a saved theme would arrive late. Put this line in <head>, before the stylesheet: it applies localStorage["facet-theme"] (only dark, light or auto) before the page paints, and the toggle then just shows it.

<script>try{var t=localStorage.getItem("facet-theme");if(/^(dark|light|auto)$/.test(t))document.documentElement.dataset.theme=t}catch(e){}</script>

Deck data

Each .f-deck__slide's data-f-slide is JSON that lists the state of every piece (by its data-f-piece name) the slide shows; a piece left out is hidden (a beam retracts to its start, so it grows again when it comes back). Positions and sizes are percentages of the board.

Piece State
Tile .f-deck__tile {"at": [left, top], "c": color, "lit": true}: lit fills it with its color.
Beam .f-deck__beam {"beam": [left, top, length]}: .f-deck__beam--v runs vertically.
Box .f-deck__shot {"box": [left, top, width, height], "fade": true}: fade fades it out.

A piece starts where it first appears, or at its data-f-from ([left, top] or a box). Bad data (not JSON, or a field without enough numbers) leaves the deck in its no-JS form, with a console warning that names the slide.

<section
  class="f-deck__slide"
  data-f-slide='{"laser": {"at": [20, 50], "c": "var(--f-deck-ink)"},
                 "b1": {"beam": [20, 50, 60]},
                 "target": {"at": [80, 50], "c": "var(--f-accent)", "lit": true}}'
>
  <h2>In straight lines.</h2>
</section>

Reduced motion and no JavaScript