# TasteEngine

**Work out what someone actually likes, by asking them to choose — not to describe.**

People are bad at articulating taste and good at picking a favourite. TasteEngine takes a
topic, has an LLM invent a set of distinct options, then shows them two at a time until one
is left standing. What comes out is a structured preference profile you can paste into a
system prompt, a lorebook, a config file, or anywhere else.

It is one embeddable web component with **no runtime dependencies**, and it works with a
model running on your own machine.

```html
<script src="dist/taste-engine.js"></script>
<taste-engine topic="Cyberpunk roleplay setting"></taste-engine>
```

**[▶ Try the live demo](https://omar1001.github.io/taste-engine/demo/)** — it ships with
sample cards built in, so it runs with no model, no API key and no network.

---

## Contents

- [What it does](#what-it-does)
- [Quickstart](#quickstart)
- [Connecting a model](#connecting-a-model)
  - [Ollama](#ollama) · [LM Studio](#lm-studio) · [Cloud APIs](#cloud-apis)
  - [About API keys in the browser](#about-api-keys-in-the-browser)
- [Embedding it](#embedding-it)
- [Configuration](#configuration)
- [Events and JavaScript API](#events-and-javascript-api)
- [Export formats](#export-formats)
  - [Writing your own template](#writing-your-own-template)
- [Theming](#theming)
- [Architecture](#architecture)
- [Development](#development)
- [Licence](#licence)

---

## What it does

1. **You name a topic.** "Cyberpunk roleplay setting", "minimalist UI style", "coffee
   flavour preference" — anything.
2. **The model invents 8–16 distinct options**, each a card with a title, description, tags
   and named attributes. The prompt works hard to make them genuinely different from one
   another, because two similar cards make the comparison meaningless.
3. **You compare them two at a time.** Keep one, keep both, fuse them into a hybrid, edit
   one, or throw one away and get a fresh alternative.
4. **You get a profile out.** Not just the winner — the whole record: what you kept, what
   you rejected, what you merged, the notes you wrote, and the themes that recur.

Each round is one keystroke: <kbd>A</kbd> / <kbd>B</kbd> to choose, <kbd>M</kbd> to merge,
<kbd>K</kbd> to keep both, <kbd>R</kbd> to re-roll, <kbd>N</kbd> for neither,
<kbd>U</kbd> to undo.

### What it's for

- **Roleplay and worldbuilding** — build a character or setting profile, export it as a
  SillyTavern lorebook or an author's note.
- **Design discovery** — find out which visual direction a client actually wants before
  building three mockups.
- **Onboarding** — replace a preferences form nobody fills in honestly.
- **Recommendations** — elicit a taste vector without a cold-start questionnaire.

---

## Quickstart

**Just look at it:** open `demo/index.html` in a browser. That is the whole setup — it runs
on built-in sample data.

**Put it in a page:**

```html
<script src="dist/taste-engine.js"></script>
<taste-engine></taste-engine>
```

The first time it loads it asks how to reach a model. That is the only setup step.

**Use it as a module:**

```bash
npm install taste-engine   # or just copy src/
```

```js
import 'taste-engine';           // defines <taste-engine>
```

Or import the pieces without the UI — the tournament engine and template renderer are plain
functions with no browser dependencies:

```js
import { createTournament, reduce, summarize, renderTemplate } from 'taste-engine';
```

---

## Connecting a model

TasteEngine runs entirely in the browser and talks to the model directly. There is no
server component, which means the model has to be reachable *from the page*.

### Ollama

```bash
ollama pull llama3.2
```

⚠️ **Ollama refuses requests from web pages by default.** This is the single most common
reason the tool appears broken. You must allow browser origins:

```bash
# macOS / Linux
OLLAMA_ORIGINS="*" ollama serve
```

```powershell
# Windows: set it, then restart Ollama from the tray
setx OLLAMA_ORIGINS "*"
```

Then in the connection screen choose **Ollama**, which fills in
`http://localhost:11434/v1`, and press **Test connection**.

> `OLLAMA_ORIGINS="*"` lets *any* page you visit reach your local Ollama. On a personal
> machine that is normally fine. To be stricter, set it to the exact origin you serve
> TasteEngine from, e.g. `http://localhost:5173`.

### LM Studio

1. Open the **Local Server** tab.
2. Turn on **Enable CORS** — without it the browser request is blocked.
3. Start the server, load a model, and press **Test connection** here.

The preset fills in `http://localhost:1234/v1`.

### Cloud APIs

Presets exist for OpenAI, Groq and OpenRouter (all OpenAI-compatible), plus a dedicated
Anthropic provider. Each needs an API key. Read the next section first.

Any other OpenAI-compatible server — vLLM, LocalAI, llama.cpp, Together — works by entering
its base URL. Adding a "new provider" is usually just a URL, not code.

### About API keys in the browser

**A key you enter here is stored in your browser and sent from the page.** There is nowhere
else for it to go: this is a client-side tool with no backend.

| Situation | Recommendation |
|---|---|
| Running it locally, for yourself | **Use a local model.** No key exists to leak. Best option. |
| You want a cloud model, locally | Fine. The key is in your own browser on your own machine. |
| Embedding it on a public site | **Do not ship a key.** Anything else running on that page can read it. Put your own proxy in front of the API and point `baseUrl` at that, or let each visitor enter their own key. |

Keys are excluded from backup files by default for the same reason.

Anthropic additionally requires an explicit opt-in header for browser requests
(`anthropic-dangerous-direct-browser-access`), which this provider sends. Anthropic chose
the word "dangerous" deliberately, and the table above is why.

---

## Embedding it

**Plain HTML** — see [`demo/embed-plain.html`](demo/embed-plain.html)
([live](https://omar1001.github.io/taste-engine/demo/embed-plain.html)), which works from a
`file://` double-click:

```html
<script src="dist/taste-engine.js"></script>
<taste-engine topic="Coffee flavour preference" locked-theme="paper" hide="connection"></taste-engine>
```

**React** — a custom element is just a DOM element. The only React-specific detail is that
custom events need `addEventListener` rather than an `onX` prop. See
[`demo/embed-react.html`](demo/embed-react.html)
([live](https://omar1001.github.io/taste-engine/demo/embed-react.html)):

```jsx
function TasteWidget() {
  const ref = useRef(null);
  useEffect(() => {
    const onDone = (e) => console.log(e.detail.winner.title);
    ref.current.addEventListener('te-complete', onDone);
    return () => ref.current.removeEventListener('te-complete', onDone);
  }, []);
  return <taste-engine ref={ref} theme="dark-arcade" />;
}
```

**Vue / Svelte / Angular** — the same. Vue and Svelte bind custom events natively
(`@te-complete` / `on:te-complete`).

**In an iframe** — works, and gives you hard isolation. You lose direct access to the
events, so post the result out with `window.parent.postMessage` from inside.

The component renders into a shadow root, so its CSS cannot leak into your page and your
page's CSS cannot leak in. Style it through the [documented tokens](#theming).

---

## Configuration

Three layers, each overriding the last:

```
built-in defaults  <  config.json  <  HTML attributes  <  the .config property
```

| Attribute | Property | Default | What it does |
|---|---|---|---|
| `topic` | `topic` | `""` | Prefills the topic box |
| `auto-start` | `autoStart` | `false` | Start generating immediately (needs a topic) |
| `cards` | `cards` | `10` | How many options to generate |
| `provider` | `provider` | *(ask)* | `demo`, `openai-compatible`, `anthropic`. Setting it skips the first-run screen |
| `base-url` | `baseUrl` | — | Model server address |
| `model` | `model` | — | Model name |
| `api-key` | `apiKey` | — | **Avoid on public sites.** See above |
| `theme` | `theme` | `clean-modern` | Default theme; the user may change it and their choice is remembered |
| `locked-theme` | `lockedTheme` | — | Forces a theme and disables the appearance editor |
| `hide` | `hide` | — | Comma list: `connection`, `theme`, `templates`, `backup` |
| `storage-prefix` | `storagePrefix` | `taste-engine:` | Namespace for saved data |
| `config` | — | — | URL of a JSON config file |

See [`config.example.json`](config.example.json) for the file form.

```js
// From script, highest precedence:
document.querySelector('taste-engine').config = { cards: 6, theme: 'paper' };
```

---

## Events and JavaScript API

All events bubble and cross the shadow boundary.

| Event | Fires when | `event.detail` |
|---|---|---|
| `te-ready` | Initialised | `{ config }` |
| `te-start` | A run begins | `{ topic, cards }` |
| `te-round` | A decision is made | `{ action, state }` |
| `te-complete` | A run finishes | The full summary |
| `te-export` | User copies or downloads | `{ format, text }` |
| `te-error` | Something fails | `{ message, kind, hint }` |

```js
const engine = document.querySelector('taste-engine');

await engine.ready;                    // resolves once initialised
engine.addEventListener('te-complete', (e) => {
  console.log(e.detail.winner.title);  // "Neon Undertow"
  console.log(e.detail.tags);          // ["noir", "corporate", ...]
});
```

| Method | Does |
|---|---|
| `await engine.ready` | Resolves when the element has initialised |
| `engine.start(topic, count?)` | Begin a run |
| `engine.reset()` | Discard the run, back to the topic screen |
| `engine.getSummary()` | The current summary, or `null` |
| `engine.exportAs(formatId)` | `{ text, format, error }` |
| `engine.setStorage(storage)` | Swap the storage backend |

### Custom storage

Saved themes, templates and preferences go through a four-method interface, so you can put
them in your own backend instead of `localStorage`:

```js
engine.setStorage({
  get:    (key) => myCache[key],
  set:    (key, value) => { myCache[key] = value; syncToServer(key, value); },
  remove: (key) => { delete myCache[key]; },
  keys:   () => Object.keys(myCache),
});
```

Values are JSON-serializable; the methods are synchronous (keep a local cache and sync
behind the scenes).

---

## Export formats

| Format | Output |
|---|---|
| **JSON** | The complete record, including every comparison |
| **Markdown** | A readable summary |
| **YAML** | The same data, easier to hand-edit |
| **System prompt** | A block to paste into an assistant's system prompt or author's note |
| **Lorebook** | SillyTavern World Info entries, one per kept option |

Markdown and the system prompt are ordinary templates you can fork. JSON, YAML and the
lorebook are built structurally, so a quote in a card title cannot produce a broken file.

### Writing your own template

Open **Templates**, fork a built-in, and edit it against a live preview of your own session.

The syntax is a small Mustache subset. It cannot execute expressions — templates can read
data and loop, and that is all.

```handlebars
# {{topic}}

Preferred: {{winner.title}}
{{#if winner.note}}
Note: {{winner.note}}
{{/if}}

{{#each keepers}}
{{@number}}. {{title}} — {{join tags ", "}}
{{/each}}

{{count rejected}} rejected.
```

| Syntax | Meaning |
|---|---|
| `{{path.to.value}}` | Insert a value (missing values render empty) |
| `{{#if x}}…{{else}}…{{/if}}` | Conditional. Empty arrays and objects are falsy |
| `{{#unless x}}…{{/unless}}` | Inverse |
| `{{#each list}}…{{/each}}` | Loop, with `{{this}}`, `{{@index}}`, `{{@number}}`, `{{@first}}`, `{{@last}}` |
| `{{../x}}` | Reach into the enclosing scope |
| `{{! comment }}` | Dropped |

Helpers: `join`, `upper`, `lower`, `trim`, `count`, `json`, `indent`.

**Available fields**

| Field | Is |
|---|---|
| `topic`, `generatedAt`, `generatedDate` | Run metadata |
| `winner` | The final card: `.title`, `.description`, `.tags`, `.attributes`, `.note` |
| `favourites` | Cards kept via "keep both" |
| `keepers` | Winner + favourites, winner first |
| `rejected` | Cards turned down |
| `absorbed` | Cards folded into a hybrid by a merge — *not* rejected |
| `notes` | `{ title, note }` for every card with a note |
| `tags` | Recurring tags, most frequent first |
| `rounds` | The full comparison log |
| `stats` | `.roundsPlayed`, `.rerolls`, `.merges`, `.considered` |

---

## Theming

Three built-in themes — **Clean Modern**, **Dark Arcade**, **Minimal Paper**. The
appearance editor edits every token live and can save your own.

Everything visual is a CSS custom property. To theme an embedded copy from your own
stylesheet, set them on the element:

```css
taste-engine {
  --te-accent: #7c3aed;
  --te-bg: #faf7ff;
  --te-card-radius: 2px;
  --te-font-display: 'Playfair Display', serif;
}
```

Token groups: **Surfaces** (`--te-bg`, `--te-surface`, `--te-surface-alt`, `--te-border`),
**Text** (`--te-text`, `--te-text-muted`, `--te-font`, `--te-font-display`,
`--te-font-mono`), **Accents** (`--te-accent`, `--te-accent-text`, `--te-accent-soft`,
`--te-danger`), **Cards** (`--te-card-bg`, `--te-card-border`, `--te-card-radius`,
`--te-card-shadow`, `--te-card-hover-shadow`, `--te-card-title-size`,
`--te-card-title-transform`), **Shape** (`--te-radius`, `--te-space`, `--te-max-width`,
`--te-tag-radius`).

The full list with labels and types lives in
[`src/themes/tokens.js`](src/themes/tokens.js) — the editor's form is generated from it, so
it cannot drift out of date.

The editor exports a theme as JSON or as a pasteable `.css` file.

---

## Architecture

Four parts that do not know about each other, wired together by the custom element.

```
src/
├── engine/       the tournament — a pure state machine, no DOM, no network
├── llm/          provider adapters + prompts + a tolerant JSON extractor
├── output/       template renderer, YAML serializer, the built-in formats
├── themes/       design tokens and the three built-in themes
├── storage/      the swappable storage interface + localStorage/memory/backup
└── element/      the custom element and its views — the only file that touches
                  the DOM, the network and storage at the same time
```

Three decisions worth explaining:

**The tournament engine is pure.** `reduce(state, action)` returns a new state — no `fetch`,
no clock, no randomness. Merges and re-rolls need an LLM call, so those actions take the
*already-produced* card rather than making the reducer async. That is what makes the
elicitation logic testable without a browser.

**The LLM layer is one interface with three implementations.** A provider implements
`complete()` and `testConnection()`; everything card-shaped happens once in the adapter.
`openai-compatible.js` covers Ollama, LM Studio, llama.cpp, vLLM, OpenAI, Groq and
OpenRouter, because they all speak the same endpoint.

**Bad JSON is expected, not exceptional.** The primary target is a 7B model on a laptop,
which will wrap JSON in prose, use a code fence, leave a trailing comma, or run out of
tokens mid-object. [`json-repair.js`](src/llm/json-repair.js) recovers from all of those,
and structured output is requested where the server supports it so the problem often does
not arise.

---

## Development

```bash
npm start          # dev server → http://localhost:5173/demo/dev.html (loads src/ directly)
npm test           # 121 tests, node:test, no dependencies
npm run build      # regenerate dist/
```

There is no build step to *run* the source — it is plain ES modules. `dist/` exists so a
single `<script>` tag works and the demo opens from `file://`; it is committed so a clone
works immediately.

Bundle size: **112 kB raw, 35.5 kB gzipped** for `taste-engine.min.js`, including the three
themes, all five export formats and the offline demo deck.

The tests cover the parts where a silent bug produces plausible-but-wrong output: the
tournament reducer, the JSON extractor, the template renderer, the YAML serializer, and
backup import/export. The UI is verified by hand in a browser.

**Adding a provider:** create a file in `src/llm/providers/` implementing `complete()` and
`testConnection()`, call `registerProvider()`, and import it in `src/index.js`. If the
service is OpenAI-compatible, add an entry to `src/llm/presets.js` instead — no code needed.

---

## Licence

MIT. See [LICENSE](LICENSE).
