# Building a plugin

We'll build a small **"applause meter"** plugin: viewers tap to clap, and a bar shows the total. It's pure-CRDT (no server part), modeled on `@liebstoeckel/plugin-poll`.

## 1. The package

- packages/plugin-applause/
  - package.json
  - src/
    - logic.ts     schema + pure derivations (unit-tested)
    - client.tsx   the React surfaces
    - index.ts     exports
```json title="package.json"
{
  "name": "@liebstoeckel/plugin-applause",
  "version": "0.0.0", "private": true, "type": "module",
  "keywords": ["liebstoeckel-plugin"],
  "liebstoeckel": { "client": "./src/client.tsx" },
  "exports": { ".": "./src/index.ts", "./logic": "./src/logic.ts" },
  "dependencies": {
    "@liebstoeckel/plugin-sdk": "workspace:*",
    "@liebstoeckel/plugin-ui": "workspace:*"
  }
}
```

## 2. State schema + pure logic

Keep all derivations as pure functions so they're trivially testable. Model shared state to fit the SDK's **one-level** record API (see [State & sync](https://docs.liebstoeckel.app/plugins/state-and-sync/)).

```ts title="src/logic.ts"
import { schema, t, type Infer } from "@liebstoeckel/plugin-sdk";

// one entry per participant → their clap count
export const applauseSchema = schema({
  claps: t.record(t.number),
});
export type ApplauseState = Infer<typeof applauseSchema>;

export const total = (s: ApplauseState): number =>
  Object.values(s.claps).reduce((n, c) => n + c, 0);

export const mine = (s: ApplauseState, pid: string): number => s.claps[pid] ?? 0;
```

## 3. The client surfaces

A plugin's client gets `ClientProps<T>`: `{ doc, state, snapshot, role, live, participantId, theme, ui, props }`. Style against `var(--brand-*)` (or reuse the `@liebstoeckel/plugin-ui` primitives) so it's automatically on-brand.

```tsx title="src/client.tsx"
import { definePlugin, type ClientProps } from "@liebstoeckel/plugin-sdk";
import { Card, Bar, Button, Eyebrow } from "@liebstoeckel/plugin-ui";
import { applauseSchema, total, mine, type ApplauseState } from "./logic";

function ApplauseSlide(p: ClientProps<ApplauseState>) {
  const { snapshot, state, participantId } = p;
  const clap = () => state.recordSet("claps", participantId, mine(snapshot, participantId) + 1);
  const sum = total(snapshot);
  return (
**src/index.ts**
```

```tsx title="a slide"
<Plugin id="applause" />
```

### Multiple independent instances

`id` selects the plugin **type**; state lives at `plugin:<id>`. Place the same `id`
twice and both share that one slice. That is handy to *mirror* a poll across slides, but it
means a second `<Plugin id="poll">` with a different question is ignored. For
**independent** instances, give each an `instance` (a stable, author-chosen id, never
random, since it's part of the shared address). State then lives at
`plugin:<id>:<instance>`:

```tsx
<Plugin id="poll" instance="lunch"  props={{ question: "Lunch?",  options: […] }} />
<Plugin id="poll" instance="dinner" props={{ question: "Dinner?", options: […] }} />
```

Each gets its own state **and** its own presenter-console tab. Give a `presenter.title`
(below) or a `<Plugin title="…">` so the tabs read "Poll · Lunch?" rather than the
bare instance id. Omitting `instance` keeps the original single-slice behaviour.

## Layout: the slide canvas is fixed, bound your own height

A slide is rendered on a fixed **1280×720 canvas** that's scaled to fit and **clipped** (`overflow: hidden`), so the audience view, presenter view, and thumbnails stay pixel-identical. The canvas **never scrolls**. That's fine for authored slides (you control how much is on them), but a plugin whose content grows with the audience (a Q&A queue, a chat, a long results list) *will* eventually overflow and get cut off, with no way to scroll to the rest.

So: **pin your header/input and scroll the growing part yourself.** `@liebstoeckel/plugin-ui` ships a `ScrollArea` for exactly this: a bounded region that scrolls internally and won't chain its scroll to the deck:

```tsx title="src/client.tsx" {1,8,12}
import { Card, Eyebrow, ScrollArea, Stack } from "@liebstoeckel/plugin-ui";

function QueueSlide(p: ClientProps<State>) {
  return (
    <Card style={{ width: "100%", maxWidth: 520 }}>
      <Eyebrow>Questions</Eyebrow>
      {/* …input box stays pinned here… */}
      <ScrollArea>            {/* default cap: min(360px, 42vh) */}
        <Stack>
          {items.map((q) => <Row key={q.id} {...q} />)}
        </Stack>
      </ScrollArea>

  );
}
```
**Mobile scrolls for free, desktop does not:** On touch, an interactive plugin opens in a breakout sheet that already scrolls. It's the **inline / desktop** path that has no scroll boundary, so test your plugin with a *lot* of content on a desktop window. A `ScrollArea` fixes both at once, and because it has its own boundary, it stays a single clean scroll inside the breakout too.

## The client contract

| Surface | When it renders |
| --- | --- |
| `Slide` | a live server is connected: the interactive in-deck UI |
| `presenter?` | optional **presenter console**, a tab in the presenter view (see below) |
| `fallback?` | standalone `.html` **and** in build-time thumbnails; show real content from `snapshot`/`props` |
| `surfaces?` | named override points an author can replace per placement |
| `interactive?` | `false` to suppress the mobile breakout for display-only plugins (default `true`) |
| `global?` | deck-wide surfaces: an `Overlay`, and a chrome control declared as `icon` + `label` that toggles a `Panel` (see the [overview](https://docs.liebstoeckel.app/plugins/overview/)). `pinned: true` keeps the control in the mobile rail; otherwise it folds into the `⋮` menu on touch so the rail can't overflow. `panelMode: "sheet"` opens the panel full-viewport on touch (for a text input the keyboard would otherwise bury) |
**Fallbacks matter:** The `fallback` is what shows offline and in the overview thumbnail. Make it reflect the real configuration (use `props` and any seeded `snapshot`), not a generic placeholder.

## The presenter console

A plugin can claim a **tab in the presenter view**: a presenter-private surface for
a live readout (vote tallies, queue depth) *and* privileged moderation the audience
shouldn't see being done (close a poll, mark a question answered, dismiss spam).
Notes are the default tab; your console sits beside them on both desktop and mobile.

```tsx
client: {
  Slide: PollSlide,
  presenter: {
    label: "Poll",
    icon: "📊",
    // optional attention pill on the tab; return undefined/0 to hide it
    badge: (s) => totalVotes(s) || undefined,
    // optional per-instance label, to tell sibling instances apart in the tabs
    title: (s) => s.question || undefined,
    // same ClientProps as Slide (doc, state, snapshot, role, live, …)
    Console: PollConsole,
  },
},
```

**Driving the audience needs no special API.** State is deck-global and writes are
role-gated, so an audience-affecting action is just a write to your *own* state. The
`Slide` reads it and re-renders on the audience screen:

```tsx
function PollConsole({ snapshot, state, role }: ClientProps<PollState>) {
  return (
    <Card>
      <Results snapshot={snapshot} /* … */ />
      {role === "presenter" && (
        <Button onClick={() => state.set("closed", !snapshot.closed)}>
          {snapshot.closed ? "Reopen voting" : "Close voting"}
        </Button>
      )}
    </Card>
  );
}
```
**Note:** The console is **live-only** (it needs a session to moderate) and `role`-gated:
guard write actions on `role === "presenter"` so a viewer who opens the presenter
view to shadow can read the console but not moderate.

[State & sync](https://docs.liebstoeckel.app/plugins/state-and-sync/)
[Testing plugins](https://docs.liebstoeckel.app/plugins/testing/)