Trail

A contents rail for long pages, measured in reading time, with a reading line across the page. Collapsed, the rail is the article at scale: one tick per equal share of its reading time, a longer tick where a section starts, the ticks behind the violet dot one step darker. Open, it lists the titles with a bar for each section's reading time and says how much is left.

This package is the component that ships. The HTML prototypes (v1 to v6) stay in Lab as reference.

Files

src/
  model.ts      the pure part: reading time, rail layout, bars, progress, springs, text. No DOM.
  trail.ts      the rail: builds, measures, animates, handles pointer, keyboard and touch
  line.ts       the reading line across the page
  dom.ts        element builder (no innerHTML anywhere), block sampling
  strings.ts    every word Trail shows, in English
  storage.ts    where a reader's choices are kept
  react.tsx     the React wrapper
  trail.css     the styles, exilinar III tokens only
  index.ts
test/
  model.test.ts   the model (Vitest)
  react.test.tsx  the wrapper rendered on the server (Vitest)
  trail.e2e.ts    the flows in Chromium (Playwright)
  fixture/        the pages the flows run on

Put the folder where exilinar keeps its components. The package name in package.json is a placeholder.

Use it

React (the app, ramtt.com, docs):

import { Trail } from 'trail/react';
import 'trail/trail.css';

<aside className="rail-column">          {/* sticky, with a z-index above the text */}
  <Trail
    headings="#article h2, #article h3"
    article="#article"
    items={outline}                        // [{ id, title }] from the content build, rendered on the server
    preset="read"                          // 'write' in an editor
    storage={settingsStore}                // optional; localStorage by default
    contentKey={slug}                      // change it when the article changes
  />
</aside>

Without React:

import { Trail } from 'trail';
const trail = new Trail(document.querySelector('nav.rail')!, { headings: '#doc h2, #doc h3', article: '#doc' });
// later
trail.destroy();

The React wrapper renders the landmark and, if items is given, the outline as plain links, visually hidden. Search engines, screen readers and readers without JavaScript get the outline from the server; the rail replaces it on mount. React never renders children into the .trail element, so the two never fight over the DOM.

Options

OptionDefault
headings'h2'selector run inside root, or the elements
articleclosest articlethe element whose end and reading time count
measure'time''length' measures in page height instead
wpm238silent reading of non-fiction, Brysbaert 2019
length0.5rail length as a share of the window
barstrueopen: tick length is the section's reading time
sweeptruecollapsed: the tick below the dot fills as the dot moves towards it
fadetruea long open list fades at the ends that have more
skimtruecollapsed: the section under the pointer is named; resting 700 ms opens
preset'read''read' or 'write', see below
storagelocalStorea TrailStorage adapter
stringsEnglishreplace any of the words

Methods: refresh(), measure(), set(feature, on), setMeasure(), setPreset(), setLine(partial, save), resetLine(), hold(on), destroy(). Getters: options, readingLine, current. The root element fires trail:line with the line state whenever it changes.

Defaults

WherepresetPlacement
ramtt.com articles, Current, Field Guide, docsreadbeside the text column
The docs editor, Zibaldone and documents in the app, while writingwrite16 px from the left edge of the content pane
The same documents opened to readread16 px from the left edge of the content pane

read turns the reading line on with motion, mark and fill. write turns it off: the cursor is where a writer is, a line across the text competes with the cursor and the selection, and dim would fade the paragraph being edited. The reader can change the line from the settings button in the open rail. Their choice is kept per preset, so a choice made while reading does not change the editor.

Host rules

1. The rail's container needs a z-index above the text. Text faded by dim gets its own stacking context and would otherwise paint over the panel and the settings. 2. Give the rail a sticky container. Trail keeps its length inside the room below the sticky top. 3. Trail reads these exilinar tokens: paper, raised, ink, ink-2 to ink-4, a5, a35, line-4, accent-glyph, e2, px, r-row, font, fs-12, lh-12, tracking. 4. Every word Trail shows must be in VOCABULARY.md before it ships. The list is in VOCABULARY-ENTRIES.md.

What is tested

heading rows, the bar scale, progress through a section and through the gap above a heading, the row state and the sweep, the dot spring at 30, 60 and 120 fps, the words for time, ids from titles, stored line state.

section; skim names the section and opens only on rest; a pointer crossing the rail does not open it; the settings open from the button, change and keep the line, and close with the panel; Escape closes the settings and then the panel; one tab stop on the current section, arrows, Enter; the reading line's fill, mark and drag; the write preset; destroy leaves no line and no listeners; touch taps; the React wrapper mounts over the outline and cleans up on unmount.

pnpm typecheck      # tsc, strict
pnpm test           # vitest
pnpm e2e            # builds the fixtures, then playwright

Notes

reader scrolls.