trieur.

Plugins, and what this weighs

The deck’s job is small and universal: deal a card, place the zones, follow a pointer, file what the gesture chose. Everything past that — throwing, telemetry, a pad under the thumb, a menu that follows the cursor — is somebody’s particular idea about sorting, and none of them belong in the bundle of a page that does not want them.

Two hooks

interface DeckPlugin<T> {
  name?: string;
  setup?(deck): (() => void) | void;                 // once, may touch the DOM, returns a teardown
  aim?(ctx, deck): PlacedZone | null | undefined;    // where is this gesture pointing?
}

new Deck(el, { plugins: [flick(), myPlugin()] });

setup is for everything a plugin wants to observe or add: the trieur:* events, zones, current, zoneAt(), highlight(), commit() are all public, so a plugin sees what a host sees. Return a function and the deck calls it on destroy().

aim is the one thing events cannot do: decide before the deck acts. It runs on every move and again on release, with the gesture in stage coordinates:

interface AimContext<T> {
  phase: 'move' | 'end';
  from: Point;   // the card's resting centre
  at: Point;     // where the pointer is
  v: Point;      // velocity, px/ms, fitted over the last 100ms
  dist: number;
  cancelled: boolean;
  fallback: PlacedZone | null;   // what the deck would decide on its own
  item: T | undefined;
}

Return a zone to file into, null for nowhere, or undefined to keep no opinion — the deck’s own answer then stands, or the next plugin’s. First opinion wins.

There is no registry, no lifecycle beyond setup and teardown, and no way for a plugin to reach inside. If a plugin needs something the public surface does not have, that is a gap in the public surface rather than a reason to open the deck up.

The worked example

The throw used to be five options on DeckOptions and eighty lines inside the deck. It is now an import:

import { Deck } from '@trieur/core';
import { flick } from '@trieur/core/flick';

new Deck(el, { plugins: [flick({ ms: 170, min: 0.6, bias: 0.4, debug: false })] });

It uses both hooks and nothing else: setup adds an SVG layer when debug is on and removes it on teardown; aim runs the projection and answers with a zone — or undefined when the release was too slow to be a throw, which leaves an ordinary drop alone. A page that never imports it carries none of it.

Special zones

Two kinds are built in, because they are about the zone rather than about a behaviour:

{ id: 'inbox' }                  // ordinary
null                             // free: dropping calls onAssign(index) instead
{ id: 'open', disabled: true }   // present, in its place, and not available

A disabled zone keeps its position and its key, refuses the card, and is skipped by the model, by the keyboard, by a tap and by the aim. That matters more than removing it: a menu whose items move is a menu you have to read again.

Anything more particular than that is a plugin. A zone that only accepts items matching a predicate, for instance, is six lines:

const onlyImages: DeckPlugin<File> = {
  aim: (ctx) => {
    const zone = ctx.fallback;
    if (!zone || zone.id !== 'photos') return undefined;   // not our business
    return ctx.item?.type.startsWith('image/') ? undefined : null;
  },
};

What it weighs

Measured with bun build --minify, gzipped, on the version this page ships with:

mingzip
@trieur/core — the deck and everything still in it33.2 kB12.5 kB
…of which the six layouts6.4 kB3.1 kB
@trieur/core/flick — the throw, as a plugin1.8 kB1.0 kB
trieur.css27.4 kB8.2 kB

No dependencies, in any of it. A page that uses the deck pays about 21 kB gzipped, which is roughly one photograph.

Where that goes, if you are counting:

Inside the deckshare of the source
multi-zone: the stack, the pad, the shift latch118 lines (9%)
two piles31 lines
fullscreen22 lines
touch preview (play / stop)13 lines
keyboard without focus13 lines
the page freeze under a drag8 lines
the plugin seam itself7 lines
tap-to-file2 lines
Inside the stylesheetgzip
zone tiles1.9 kB
the card pile and its animations2.0 kB
base and variables1.1 kB
narrow-stage rules1.1 kB
the play button and its scrim1.0 kB
fullscreen0.9 kB
the held pad0.7 kB
the radial menu0.6 kB
everything else0.9 kB

Two honest conclusions from those tables. First, the stylesheet is the heavier half of the optional weight — if you ship one layout and no multi-zone, a CSS pass over the class names you actually use saves more than any JavaScript refactor would. Second, inside the JavaScript there is exactly one block worth extracting next — multi-zone — and it would need two more hooks: one for keys, one for drawing the badges. Those hooks do not exist because nothing has asked for them yet, and a seam added on speculation is a seam in the wrong place.