# @kraftwerkdesign/kd-components

Native web components for Kraftwerk agency projects. No runtime framework — every
component is a plain `HTMLElement` subclass that ships as ESM with TypeScript types.

## Install

```bash
npm install @kraftwerkdesign/kd-components
```

## Usage

Components are **not** registered automatically on import. You choose how they get
into the custom-element registry.

### Register everything

```js
import '@kraftwerkdesign/kd-components/auto'
```

### Register a single component

```js
import '@kraftwerkdesign/kd-components/lite-vimeo'
import '@kraftwerkdesign/kd-components/lite-youtube'
```

### Register manually (tree-shakeable, no side effects)

```js
import { defineLiteVimeo, defineLiteYouTube } from '@kraftwerkdesign/kd-components'

defineLiteVimeo() // registers <lite-vimeo>
defineLiteYouTube() // registers <lite-youtube>
```

Each `define*` helper is idempotent (guarded by `customElements.get`) and accepts an
optional custom tag name:

```js
defineLiteVimeo('kd-vimeo')
```

The raw classes are exported too, if you need to subclass or register them yourself:

```js
import { LiteVimeoEmbed, LiteYTEmbed } from '@kraftwerkdesign/kd-components'
```

## Cookie consent gating

Both components expose two optional static hooks so you can block the third-party
iframe (and its cookies) until the visitor has granted consent. Wire them once from
your app entry — if left unset, the components load normally.

```js
import { LiteVimeoEmbed, LiteYTEmbed } from '@kraftwerkdesign/kd-components'

for (const El of [LiteVimeoEmbed, LiteYTEmbed]) {
  // Checked on click. May open your consent UI. Return true to allow the load.
  El.consentGate = () => requireConsent('functionality')
  // Checked on autoload / scroll-into-view. Must NOT prompt — just report state.
  El.consentReady = () => hasConsent('functionality')
}
```

- `consentGate?: () => boolean` — runs when the user activates the placeholder
  (click / Enter / Space). Return `false` to suppress the iframe; this is the place
  to trigger your consent modal.
- `consentReady?: () => boolean` — runs for passive triggers (`autoload`,
  IntersectionObserver). Returns `false` to defer loading without prompting; the
  element stays observed, so a later grant lets the next intersection load it.

### Blocked-consent notice

When a **click** is blocked (`consentGate` returns `false`), the component shows a
dismissible notice pinned to the bottom of the poster explaining why the video did
not load. It carries a default message and a close button out of the box:

> This video is blocked until you accept cookies.

The notice is a click-gate affordance only. Passive triggers that fail
`consentReady` (`autoload`, scroll-into-view) defer silently and never show it —
they leave the poster untouched so a later consent grant can load the video on the
next intersection.

**Lifecycle.** The notice is toggled by the `consent-blocked` class on the internal
`#frame`. It appears on a blocked click and hides again automatically when:

- the visitor dismisses it with the close button,
- consent is later granted and the iframe successfully loads, or
- the `videoid` / `playlistid` changes.

Dismissing it does not grant consent — the next click re-runs `consentGate` and
re-shows the notice if still blocked.

Override the message with the `consent-notice` slot (links and markup are allowed):

```html
<lite-vimeo videoid="76979871">
  <p slot="consent-notice">Accept cookies to watch. <a href="/privacy">Learn more</a></p>
</lite-vimeo>
```

Theme it with custom properties set on the host element:

| Custom property           | Default             | Controls           |
| ------------------------- | ------------------- | ------------------ |
| `--consent-notice-bg`     | `rgb(0 0 0 / 0.82)` | Overlay background |
| `--consent-notice-color`  | `#fff`              | Text color         |
| `--consent-notice-radius` | `6px`               | Corner radius      |

For anything the custom properties don't cover, the notice and its close button are
exposed as shadow parts:

```css
lite-vimeo::part(consent-notice) {
  font-size: 16px;
}
lite-vimeo::part(consent-notice-close) {
  /* custom close-button styling */
}
```

**Accessibility.** The notice container is a `role="status"` live region, the close
button carries `aria-label="Dismiss"` and a visible `:focus-visible` outline, and a
`forced-colors` fallback swaps to system `Canvas`/`CanvasText` colors with a border.

## Components

Lightweight, fast-painting video embeds that only load the real iframe on
interaction (or when scrolled into view with `autoload`). Both components share
the same core API:

| Attribute       | Description                                                           |
| --------------- | --------------------------------------------------------------------- |
| `videoid`       | Video ID (required)                                                   |
| `videotitle`    | Accessible title used for the play button, poster and iframe          |
| `videoplay`     | Localized "Play" label (default `Play`)                               |
| `videostartat`  | Start time in seconds, e.g. `30` (Vimeo also accepts `1m30s`)         |
| `params`        | Extra query params for the player URL, e.g. `controls=0&rel=0`        |
| `posterloading` | `loading` attr for the poster image (default `lazy`)                  |
| `autoload`      | Load the iframe when scrolled into view via IntersectionObserver      |
| `autoplay`      | Autoplay (muted) when injected via `autoload`; clicks always autoplay |

Provide a custom poster with the `poster` slot — the component then skips its own
thumbnail requests entirely:

```html
<lite-vimeo videoid="76979871">
  <img slot="poster" src="/my-poster.jpg" alt="" />
</lite-vimeo>
```

When the real iframe is injected, the element dispatches a bubbling custom event
(`liteVimeoIframeLoaded` / `liteYoutubeIframeLoaded`) with the video id in
`event.detail.videoId`.

If your Content-Security-Policy requires nonces on inline styles, set
`window.liteVimeoNonce` / `window.liteYouTubeNonce` before the elements are
registered.

### Styling the play button

Both components share one play button (a dark disc, thin ring, and white
triangle). Recolor and resize it with CSS custom properties set on the element:

| Custom property   | Default                  | Controls            |
| ----------------- | ------------------------ | ------------------- |
| `--play-btn-fill` | `rgb(0 0 0 / 0.68)`      | Disc fill           |
| `--play-btn-ring` | `rgb(166 145 95 / 0.74)` | Disc outline ring   |
| `--play-btn-icon` | `#fff`                   | Play triangle       |
| `--play-btn-size` | `130px`                  | Button width/height |

```css
lite-youtube {
  --play-btn-fill: rgb(255 255 255 / 0.9);
  --play-btn-ring: #f00;
  --play-btn-icon: #f00;
  --play-btn-size: 90px;
}
```

For anything the custom properties don't cover, the button is exposed as a
shadow part — reach it via `::part(play-button)` (including states like
`::part(play-button):hover`):

```css
lite-vimeo::part(play-button) {
  box-shadow: 0 0 0 4px #fff;
}
```

### `<lite-vimeo>`

```html
<lite-vimeo videoid="76979871" videotitle="The Mountain"></lite-vimeo>
```

| Attribute   | Description                    |
| ----------- | ------------------------------ |
| `videohash` | Private-video hash (`h` param) |

The player URL always includes `dnt=1` (Do Not Track).

### `<lite-youtube>`

Adds playlist and YouTube Shorts support:

```html
<lite-youtube videoid="dQw4w9WgXcQ" videotitle="Never Gonna Give You Up"></lite-youtube>
```

| Attribute       | Description                                                     |
| --------------- | --------------------------------------------------------------- |
| `playlistid`    | Playlist ID (used instead of `videoid`)                         |
| `posterquality` | Thumbnail quality, e.g. `hqdefault`, `maxresdefault`            |
| `nocookie`      | Use `youtube-nocookie.com` (default; set `="false"` to opt out) |
| `short`         | Enable YouTube Shorts styling/behavior on mobile                |

## Development

```bash
npm install
npm run dev      # serve demo/index.html with Vite
npm run build    # build the publishable library into dist/
npm run format   # prettier
```

## Publishing

Publishing is automated with npm **OIDC trusted publishing** — no `NPM_TOKEN`
secret, and provenance is attached automatically. Run one release script; it bumps
the version, tags it, pushes, and creates a GitHub Release, which triggers the
`publish.yml` workflow to typecheck, build, and publish to npm.

```bash
npm run release:patch   # 0.1.0 -> 0.1.1
npm run release:minor   # 0.1.0 -> 0.2.0
npm run release:major   # 0.1.0 -> 1.0.0
```

**One-time setup:** on npmjs.com, open the package settings and add a Trusted
Publisher pointing at the `Kraftwerk-Design/kd-components` repo and the
`publish.yml` workflow. Until the package exists on npm, the very first publish
may need to be run manually (`npm publish`) or the trusted publisher configured
ahead of time under the org.

## License

[MIT](./LICENSE.md). The `lite-youtube` and `lite-vimeo` components are derived
from the MIT-licensed [justinribeiro/lite-youtube](https://github.com/justinribeiro/lite-youtube)
and [slightlyoff/lite-vimeo](https://github.com/slightlyoff/lite-vimeo).
