# K27 Component Library

Reusable front-end modules extracted from **Metals Stack** (`K27-MD-D503-METALS`, Lucy v1.9.2).
Vanilla JS, no build step, no dependencies, no framework. Drop the files in, add one line of markup.

| Module | Files | What it is |
|---|---|---|
| **A · Theme** | `theme/k27-theme.css` + `theme/k27-theme.js` | 33 themes in a pinned one-tap swatch strip, CSS-variable driven, remembers your pick — **v1.1** |
| **B · Voice** | `voice/k27-voice.js` | ElevenLabs + system speech with provider routing, voice picker, phrase bank |

Both work **completely independently**. Load one, the other, or both.

---

## Five-minute install

```html
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="theme/k27-theme.css">
</head>
<body data-theme="sunset">

  <div id="k27-theme"></div>     <!-- Module A mounts here -->
  <div id="k27-voice"></div>     <!-- Module B mounts here -->

  <script src="theme/k27-theme.js"></script>
  <script src="voice/k27-voice.js"></script>
</body>
</html>
```

That's it. Both modules auto-initialise on `DOMContentLoaded` and build their own UI.

**The two markup hooks:**

1. `<body data-theme="sunset">` — the theme attribute lives here. Any starting theme id works; if a saved preference exists it wins.
2. `<div id="k27-theme"></div>` / `<div id="k27-voice"></div>` — where each UI renders. If you omit them, the modules create their own containers (picker at the top of `<body>`, Voice Lab at the bottom).

Open `demo.html` to see both running on placeholder content.

---

## Module A — Theme

### The picker (v1.1)

The picker is a **pinned strip of colour chips**. It renders at the top of its mount, is visible
on load with nothing to expand, and **sticks to the top of the viewport** — so you can keep
flipping themes while scrolled halfway down a page and watch the content change under you.

- **One tap = theme applies.** No category to open first, no drill-down.
- **You pick by look, not by name.** Each chip shows that theme's three real colours.
- All 33 are reachable by swiping the strip sideways.
- **Categories are a filter, not a gate** — an optional chip row *above* the strip
  (All · Sci-Fi · Movies · Games · Fashion · Cartoon · Gradient · Nature · Classic)
  that narrows what the strip shows. Default is **All**.
- The active theme's chip is outlined and inverted, and is scrolled into view on load.

Turn the filter row off with `showCategories: false`; unpin with `sticky: false`.

> **Upgrading from v1.0:** nothing to change. Same files, same mount, same `K27Theme` API.
> The old `details`-based CSS is still in the stylesheet, so a page pinned to v1.0's JS keeps working.

### How theming works

Every theme is a block of **30 CSS custom properties** on `[data-theme="id"]`. Changing the theme swaps the variables; your page's own CSS reads them. Nothing in this module targets your elements.

To make your page themeable, replace colour literals with variables:

```css
/* before */
.card { background: #ffffff; color: #1a1a1a; border-left: 4px solid #d4af37; }

/* after */
.card { background: var(--surface); color: var(--text); border-left: 4px solid var(--accent); }
```

### The 30 variables

| Variable | Use |
|---|---|
| `--bg` | page background |
| `--bg-soft` | tinted background for callouts / notes |
| `--surface` | card and panel background |
| `--surface-2` | secondary surface (inputs, inactive buttons) |
| `--text` | primary text |
| `--text-muted` | secondary text, labels |
| `--text-dim` | tertiary text, captions, footers |
| `--border` | standard border |
| `--row-border` | divider between list rows |
| `--accent` | primary accent (buttons, active states, highlights) |
| `--accent-2` | deeper accent (hover, gradient partner) |
| `--silver` | second series colour |
| `--platinum` | third series colour |
| `--palladium` | fourth series colour |
| `--pos` | positive / success (green) |
| `--neg` | negative / error (red) |
| `--warn` | warning (amber) |
| `--tag-bg` / `--tag-text` | pill and badge pair |
| `--total-bg` / `--total-text` / `--total-meta` | inverted "hero card" trio |
| `--header-grad` / `--header-text` | header banner pair |
| `--pie-border` | border colour for chart segments / swatches |
| `--shadow` / `--shadow-lg` | standard and elevated shadow |
| `--soft-grad` | subtle gradient panel background |
| `--feed-grad` / `--feed-border` | live-data strip pair |

> `--silver` / `--platinum` / `--palladium` are metals-era names for what are now just **series colours 2, 3 and 4**. Names were kept so existing K27 pages keep working. Alias them in your own CSS if the naming bugs you: `.series-2 { color: var(--silver); }`

### Optional base layer

`k27-theme.css` includes a small block that paints `body` from `--bg` / `--text`, so a brand-new page visibly responds to a theme change with zero effort. It's clearly marked — **delete it** if your page already sets its own background.

### The 33 themes

| Category | Theme ids |
|---|---|
| 🌆 Sci-Fi / Cyber | `dark` `cyberpunk` `matrix` `tron` `hud` `outrun` |
| 🎬 Movies / Pop | `batman` `nasa` `panic` |
| 🎮 Games / Arcade | `pacman` `nes` `gameboy` `minecraft` `sonic` `mario` |
| 💋 Fashion / Playful | `barbie` `cottage` `y2k` `rose` |
| 🎨 Cartoon / Goofy | `satmorn` `crayola` |
| 🌅 Gradient / Ombre | `sunset` `aurora` `ocean` `vapor` |
| 🌿 Nature / Earth | `forest` `money` `stadium` |
| 📰 Classic / Clean | `cloud` `cloud-light` `newsprint` `mono` `solarized` |

### API

```js
K27Theme.set('matrix');        // apply a theme
K27Theme.get();                // → 'matrix'
K27Theme.list();               // → ['dark','cyberpunk',...] (33)
K27Theme.info('matrix');       // → { label:'Matrix', swatches:[...], category:'🌆 SCI-FI / CYBER' }
K27Theme.filter('🎮 GAMES / ARCADE');  // v1.1 — narrow the strip. '*' = All. Does NOT change the theme.
K27Theme.categories;           // the full registry
K27Theme.version;              // → '1.1'
K27Theme.onChange(function (id, label) { ... });
```

Also fires a DOM event you can listen to from anywhere:

```js
document.addEventListener('k27:theme-change', e => console.log(e.detail.theme));
document.addEventListener('k27:picker-open',  e => { /* v1.1: a category filter was tapped */ });
```

### Options

```js
window.K27_THEME_MANUAL = true;   // set BEFORE the script tag to stop auto-init
```
```js
K27Theme.init({
  mount:          '#sidebar-theme',  // selector or element
  root:           document.body,     // element that carries data-theme
  storageKey:     'k27-theme',       // use 'metals-theme' to inherit the Metals Stack preference
  defaultTheme:   'sunset',
  title:          "🎨 Everybody's Got Choices",
  sticky:         true,              // false = strip scrolls away with the page
  showCategories: true,              // v1.1 — false = hide the category filter row
  openOnLoad:     false               // v1.0 compat: accepted and ignored, nothing collapses now
});
```

### Adding a theme

**Option 1 — CSS + JS registration (recommended, survives reload):**

```css
/* your-themes.css, loaded after k27-theme.css */
[data-theme="sbst"] {
  --bg: #0b1a2b;  --bg-soft: #12263c;  --surface: #16304a;  --surface-2: #1d3b5a;
  --text: #eef4fb; --text-muted: #9db3c9; --text-dim: #6b829a; --border: #244763;
  --accent: #ffb703; --accent-2: #fb8500;
  --silver: #c9d6e3; --platinum: #7fb0c9; --palladium: #a58fd6;
  --total-bg: #071320; --total-text: #ffb703; --total-meta: #9db3c9;
  --pos: #3ddc97; --neg: #ff6b6b; --warn: #ffb454;
  --tag-bg: #244763; --tag-text: #ffb703; --row-border: #244763;
  --header-grad: linear-gradient(135deg, #16304a, #ffb703); --header-text: #ffffff;
  --pie-border: #16304a; --shadow: 0 1px 4px rgba(0,0,0,.4); --shadow-lg: 0 4px 16px rgba(0,0,0,.5);
  --soft-grad: linear-gradient(135deg, #12263c, #16304a); --feed-grad: #12263c; --feed-border: #ffb703;
}
```
```js
K27Theme.add({ id:'sbst', label:'South Bay', category:'🌿 NATURE / EARTH',
               swatches:['#0b1a2b','#ffb703','#eef4fb'] });
```

**Option 2 — all-in-JS (no CSS file edit):** pass a `vars` object and the module injects the rule for you.

```js
K27Theme.add({ id:'sbst', label:'South Bay', category:'Custom',
               swatches:['#0b1a2b','#ffb703','#eef4fb'],
               vars:{ '--bg':'#0b1a2b', '--text':'#eef4fb', '--accent':'#ffb703' /* ...all 30 */ } });
```

**Removing:** `K27Theme.remove('gameboy')` drops it from the picker. The CSS block can stay — it just won't be listed.

Define **all 30** variables in a new theme. Anything you skip falls back to the `:root` defaults (Cloud Dancer, a light theme), which looks wrong on a dark theme.

### Class names

**v1.1 strip:** `k27-theme-bar` (now a `div`, sticky), `k27-tb-head`, `k27-tb-cats`, `k27-cat-chip`, `k27-tb-strip`, `k27-theme-btn`, `k27-hide`.

**v1.0 drill-down (retained for back-compat):** the picker used `k27-theme-bar` (as a `details`), `k27-theme-cats`, `k27-theme-cat`, `k27-theme-rows`, `k27-theme-btn`, plus generic children `.title` `.sub` `.active-theme` `.preview-dots` `.pd` `.chev` `.cat-chev` `.cat-name` `.cat-count` `.swatches` `.sw` `.nm` (all scoped under a `k27-` parent). The picker chrome is deliberately **always black regardless of active theme** — it's a control surface, not content.

---

## Module B — Voice

### 🔴 API key handling — read this first

**The ElevenLabs key lives in `localStorage` and nowhere else.** This was done correctly in the original build and is preserved byte-for-byte. It is the entire reason the page is safe to deploy publicly.

- Never written into the HTML file
- Never committed, bundled, or inlined
- Never sent anywhere except `api.elevenlabs.io` over HTTPS
- Entered through `<input type="password">`, and the field is **blanked the instant it's saved** so it can't be read back out of the DOM
- Read on demand by `getElKey()` only; `K27Voice.hasKey()` returns a boolean and never the key itself

Any change that puts the key in markup, a config object, a data attribute, a query string, or a build artifact breaks that guarantee. **Keep it in localStorage.** The verification suite asserts this — see *Verification* below.

To move the key: the user pastes it once per browser. There is no export. That's the point.

### Provider routing

| Route | Behaviour |
|---|---|
| **Auto** (default) | ElevenLabs first → silently falls back to system speech if it fails or no key is present |
| **ElevenLabs only** | Premium or nothing; surfaces the error |
| **System only** | Free `speechSynthesis` voices, never calls the API |
| **🔇 Mute** | Silent |

The route, chosen voice, stability, and similarity all persist to `localStorage['k27-voice-config']`.

### Controls included

Key save/clear · provider routing (4 buttons) · voice picker (ElevenLabs voices grouped above system voices) · stability slider · similarity slider · refresh voices · 18-phrase sound-effect bank · custom phrase box · save-as-picker-open-default · save-as-theme-select-default · reset all sound prefs.

### API

```js
K27Voice.say('To the moon');           // speak arbitrary text through the current route
K27Voice.play('themeSelect');          // random phrase from a named event bank
K27Voice.setEvent('checkout', ['Nice pick', 'Locked in']);
K27Voice.setProvider('speech');        // 'auto' | 'elevenlabs' | 'speech' | 'mute'
K27Voice.mute(); K27Voice.unmute();
K27Voice.hasKey();                     // → true/false. Never returns the key.
K27Voice.reloadVoices();
```

### Wiring your own phrases

```html
<script>
window.K27_VOICE_OPTIONS = {
  // event banks — one phrase is chosen at random per fire
  events: {
    pickerOpen:  ["What'll it be", "Pick your poison"],
    themeSelect: ["Yep", "Nice", "Locked in"],
    checkout:    ["Sold", "Cha-ching"]
  },
  // replace the tappable button bank
  phraseBank: [
    { emoji: '🔥', text: 'Fired up' },
    { emoji: '🏆', text: "That's a win" },
    'Plain strings work too'
  ]
};
</script>
<script src="voice/k27-voice.js"></script>
```

Then fire them from your own code: `K27Voice.play('checkout')`.

### Options

```js
window.K27_VOICE_MANUAL = true;   // set BEFORE the script tag to stop auto-init
```
```js
K27Voice.init({
  mount:           '#k27-voice',
  storageKey:      'k27-voice-config',
  keyStorageKey:   'elevenlabs-key',  // 🔴 shared per-origin; matches the original page
  title:           '🎙️ Voice Lab',
  injectStyles:    true,              // false = you supply the .k27-vl-* CSS
  bindThemeEvents: true               // false = don't react to k27:theme-change
});
```

Styles are injected at runtime, so there is no CSS file to include. The Voice Lab reads the same theme variables (with hardcoded fallbacks), so it looks right with or without Module A.

---

## How the two modules talk (without knowing about each other)

Module A **emits** DOM events. Module B **listens** for them. Neither imports the other.

```
k27-theme.js  ──dispatch──▶  document  ──listen──▶  k27-voice.js
                 k27:theme-change                    plays 'themeSelect'
                 k27:picker-open                     plays 'pickerOpen'
```

Delete either file and the other keeps working. To silence the coupling without removing the module: `K27Voice.init({ bindThemeEvents: false })`.

---

## Browser requirements & gotchas

| Requirement | Notes |
|---|---|
| `localStorage` | Both modules. Every access is wrapped in try/catch, so Safari Private Browsing degrades to "works but forgets" rather than throwing. |
| `speechSynthesis` | Module B system voices. Universal in modern browsers. Absent → `speakSystem()` no-ops silently. |
| `speechSynthesis.getVoices()` timing | Chrome returns `[]` on first call and populates asynchronously. Handled via `onvoiceschanged`. If the picker looks empty for a beat, that's why — hit **↻ Refresh voices**. |
| `fetch` + `async/await` | ElevenLabs calls. ES2017+; no IE, no transpile needed for any current browser. |
| `CustomEvent` | Cross-module events. Universal. |
| `<details>` / `<summary>` | The collapsible picker and categories are native HTML — no JS needed to open/close them. |
| Audio autoplay policy | Browsers block audio until the user has interacted with the page. The first phrase after page load may be swallowed; every one after a click works. `.play()` rejections are caught and ignored by design. |
| `crypto.subtle` | **Not used.** Listed here because the AES gate slated for this library will need it — and it requires a secure context (`https://` or `localhost`), so it will **not** work over `file://` or plain `http://` on a LAN IP. |

### Opening as a `file://` URL

- ✅ Theme picker, persistence, system voices, phrase bank — all fine.
- ⚠️ **ElevenLabs calls fail.** The API sends no CORS headers that accept an opaque `null` origin, so the fetch is blocked by the browser before it leaves. Auto route falls back to system speech, so nothing appears broken — you just never get premium voices.
- ⚠️ `localStorage` on `file://` is per-file in some browsers and shared-null-origin in others. Preferences may not follow you between two local HTML files.

**Test over HTTP, not `file://`:**

```bash
cd k27-components
python3 -m http.server 8099
# → http://localhost:8099/demo.html
```

---

## Verification

`demo.html` was served over HTTP and driven through a 28-check suite. All 28 pass:

- picker renders 8 categories / 33 buttons; every button has a matching CSS token block
- every one of the 33 themes defines all 30 variables
- all 33 themes apply to `data-theme` **and** persist to `localStorage`; saved value survives reload
- active pill label, preview dots, and `.active` button state all update
- `add()` / `remove()` mutate the registry and re-render
- Voice Lab renders 4 provider routes, 18 phrase buttons, both sliders, mute, refresh
- voice picker enumerates system voices and filters to English
- with no key present: fallback message shows and the **auto route falls through to system speech**
- mute suppresses output; provider choice persists
- 🔴 key is read from `localStorage` only, **never appears in the rendered DOM**, no `sk_...` literal exists in any shipped file, and the input is `type=password` and empty after save
- voice reacts to `k27:theme-change` with no reference to the theme module
- zero runtime JS errors

---

## Changelog

### v1.1 — picker redesign (theme first)

The v1.0 picker was a collapsed `<details>` bar: you opened the bar, opened a *category*, and only
then saw themes. Three taps to a colour, and on a phone the strip pushed the content off-screen —
so you couldn't see the app change without scrolling back up. That's now inverted.

1. **Theme selection is the first thing on the page.** The strip renders expanded, pinned above
   everything, visible on load with nothing to open.
2. **One tap applies a theme.** The category drill-down is gone from the critical path.
3. **Chips instead of a list.** Each theme is a chip showing its three real colours, so you pick by
   look. All 33 reachable by swiping the strip sideways.
4. **Categories demoted to a filter row** above the strip — All (default) · Sci-Fi · Movies · Games ·
   Fashion · Cartoon · Gradient · Nature · Classic. They narrow the strip; they never gate it, and
   tapping one never changes the theme.
5. **Sticky.** The strip stays pinned to the top of the viewport as you scroll, so you can keep
   flipping themes while looking at content further down the page.
6. **Active chip is visibly selected** — inverted with a gold outline — and is scrolled into view on load.
7. New: `K27Theme.filter(cat)`, `K27Theme.version`, `showCategories` option. `openOnLoad` is accepted
   and ignored. `k27:picker-open` now fires when a category filter is tapped.
8. The container is a `div`, not a `details`. **All v1.0 `details.k27-theme-bar` CSS was left in place**,
   so any page still serving v1.0's JS renders exactly as before.

No change to the 33 token blocks, the 30 variables, the storage keys, or Module B.

### v1.0 — changes made during extraction

Everything below is a deliberate change from the original single-file build.

1. **Class names namespaced** — `theme-bar` → `k27-theme-bar`, `theme-btn` → `k27-theme-btn`, `vl-*` → `k27-vl-*`, `phrase-*` → `k27-phrase-*`, and the shared helpers `.btn` / `.status-line` / `.opts-bar` / `.pos` / `.neg` / `.warn` → `.k27-*`. The originals were too generic to drop into an app that already has a `.btn`.
2. **Picker markup is now generated from a data registry** instead of 100 lines of hand-written HTML. Adding a theme is one `K27Theme.add()` call rather than a copy-paste block.
3. **Coupling cut** — the original called `playEvent()` directly from the theme click handler. Now the theme module emits events and the voice module listens, so either can ship alone.
4. **`--palladium` gap filled** — 8 themes (`tron`, `outrun`, `panic`, `nes`, `minecraft`, `mario`, `y2k`, `crayola`) never declared it, so they silently inherited Cloud Dancer's light purple. Each now uses its own `--accent-2` value; the lines are marked `/* K27: filled — absent in original */`.
5. **Newsprint serif bug fixed** — the original rule was `[data-theme="newsprint"] body { font-family: Georgia... }`, which can never match because the attribute is *on* `body`. Newsprint's serif font never actually rendered on the live site. Now `body[data-theme="newsprint"]`.
6. **Voice CSS inlined into the JS** so Module B stays a single file.
7. **Storage keys renamed** — `metals-theme` → `k27-theme`, `metals-sound-config` → `k27-voice-config`. The ElevenLabs key stays at `elevenlabs-key` so it's shared across K27 apps on the same origin. All three are configurable; pass `storageKey:'metals-theme'` to inherit the original page's saved preference.

**Not changed:** the 33 theme token blocks are byte-identical to the original (aside from the 8 `--palladium` additions), and the ElevenLabs request/auth/caching path is byte-identical.

---

## Library conventions

For the patterns queued up next — the client-side AES gate from `kali27-money` / `kali27-nodeboard`, and the mobile-hardened viewport handling from the shot-clock A2 build — follow the same shape:

```
k27-components/
  theme/  k27-theme.css  k27-theme.js
  voice/  k27-voice.js
  gate/   k27-gate.js          ← next
  viewport/ k27-viewport.css   ← next
  demo.html
  README.md
```

- One folder per module, files named `k27-<module>.{js,css}`
- A single global per module: `K27Theme`, `K27Voice`, `K27Gate`, …
- Auto-init on `DOMContentLoaded`; `window.K27_<MODULE>_MANUAL = true` opts out
- Config via `init({...})` or `window.K27_<MODULE>_OPTIONS`
- Mount into `#k27-<module>`, create the container if it's missing
- Namespace every class `k27-`, every storage key `k27-`
- Talk between modules with `k27:` DOM events only — never a direct reference
- Style with the theme variables, always with a hardcoded fallback: `var(--accent, #d4af37)`
- Secrets in `localStorage` only, never in markup

---

*Extracted from `stack_dashboard_v1.html` / Metals Stack · `K27-MD-D503-METALS` · Lucy v1.9.2 · Tony Sanguinetti*
