npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vitrina/widget

v0.22.1

Published

Vitrina embeddable chat widget for dealer websites — shadow-DOM UI, real-time SSE, publishable origin-locked key.

Readme

@vitrina/widget

Embeddable chat widget for dealer websites. Renders a floating launcher + a conversation panel inside a Shadow DOM (fully style-isolated from the host page) and speaks the Vitrina web channel protocol: it creates/resumes an anonymous visitor conversation, POSTs messages, and subscribes to a visitor-scoped SSE stream that invalidates so the widget re-fetches history — all authenticated with a publishable, origin-locked widget key (pk_…).

Two ways to embed: an NPM import (import { init }) for app/storefront codebases, or a <script> loader for any static site.


Install

This package is published to the public npm registry under the @vitrina org scope. No authentication or .npmrc configuration is required.

Option A — NPM import (app/storefront codebases)

npm install @vitrina/widget
# or: pnpm add @vitrina/widget
# or: yarn add @vitrina/widget

Option B — <script> loader (any static site, no build step)

<script>
  window.vitrinaChat = {
    publicKey: 'pk_live_xxx',
    apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
  };
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>

The loader is served by the same host the widget talks to, so a Content-Security-Policy only ever needs one Vitrina origin:

script-src https://api.vitrinadev.com;
connect-src https://api.vitrinadev.com;

See "Usage — <script> loader" below for the full config.

Option C — combined tag (assistant + Vitrina Ads attribution)

For a tenant with Vitrina Ads turned on: one tag boots the assistant above AND starts Vitrina Ads attribution tracking — no second install. See "The combined tag" below.

<script src="https://api.vitrinadev.com/tag.js?site=pk_live_xxx" async></script>

Usage — import { init }

import { init } from '@vitrina/widget';

const widget = init({
  publicKey: 'pk_live_xxx',
  apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
  vehicleId: 'veh_123',            // optional: pre-attach the inquiry to a vehicle
  // Appearance is managed in Vitrina and fetched at load. Set any of these only
  // to OVERRIDE it for this site — an inline value always wins.
  locale: 'es',                    // optional: 'es' | 'en' (the business's language otherwise)
  theme: { accent: '#2563eb', position: 'br' },
  logoUrl: 'https://…/logo.png',   // optional: your mark in the panel header
  font: 'dmSans',                  // optional: 'system' (default) or a named family
  bookingLabel: 'Agendar demo',    // optional: what the booking chip says
  welcomeMessage: 'Hola, ¿en qué te puedo ayudar?',
});

// The returned handle lets you drive the widget imperatively:
widget.open();
widget.setVehicle('veh_456');      // e.g. on an SPA route change
widget.openBooking();              // straight to the calendar (if the tenant has it)
widget.openHomeAction('sell');     // straight to a quick-action form
widget.close();
widget.destroy();                  // unmounts + aborts the SSE stream

The widget mounts itself on init(); nothing else is required.


Usage — <script> loader

For any site (no build step). Set window.vitrinaChat before the loader script, and it auto-initializes:

<script>
  window.vitrinaChat = {
    publicKey: 'pk_live_xxx',
    apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
  };
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>

That is the whole install. Appearance — colour, corner, logo, typeface, greeting, language, what the booking chip is called, and whether the panel has Home and Help tabs — is managed in Vitrina (Configuración › Conexiones › Web chat) and fetched at load, so changing it never means editing this page again. You can still pin any of it inline, and an inline value always wins:

<script>
  window.vitrinaChat = {
    publicKey: 'pk_live_xxx',
    apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
    // optional, all overrides of what Vitrina serves:
    vehicleId: 'veh_123',
    locale: 'es',
    theme: { accent: '#2563eb', position: 'br' },
    logoUrl: 'https://…/logo.png',
    font: 'dmSans',
    bookingLabel: 'Agendar demo',
    welcomeMessage: 'Hola, ¿en qué te puedo ayudar?',
  };
</script>
<script src="https://api.vitrinadev.com/widget.js" defer></script>

https://api.vitrinadev.com/widget.js always serves the current release. The URL is deliberately unversioned so a dealer never edits their HTML to receive a fix; it is cached for five minutes and revalidated with an ETag. Pin a specific version instead — https://cdn.jsdelivr.net/npm/@vitrina/[email protected]/dist/loader.global.js — only if you have a reason to hold one back.

The window.vitrinaChat object is exactly the config table below. After load, the live handle is stashed on window.vitrinaChatInstance, so the host page can call window.vitrinaChatInstance.open() / .openBooking() / .close() / .setVehicle(id) / .destroy().

The loader is defensive: it console.warns and no-ops on a missing/invalid config, is idempotent against a double-load, and never throws into the host page.


The combined tag (assistant + Vitrina Ads attribution)

@vitrina/widget/tag (served at https://api.vitrinadev.com/tag.js) is a second, separate loader for dealers who also have Vitrina Ads turned on: ONE <script> boots the chat assistant above AND starts Vitrina Ads attribution tracking, so the click that brought a visitor in is joined to whatever they do next. The plain widget.js loader above is unaffected and stays the recommended install for a tenant without Vitrina Ads.

There is nothing to configure beyond the site id — no publishable key, no tracking key, no API base to get right. The tag fetches its own config at load from Vitrina (GET /public/sites/{siteId}/tag-config), so a key rotation or turning Ads on/off later never means editing this tag again.

Links to the hosted booking page (https://app.vitrinadev.com/reserva/…) and a booking page embedded as an <iframe> get ?vt_aid=<anonymous visitor id> appended when used, so a booking made there is credited to the ad visit that led to it. Nothing to set up: the tag learns the booking page's address from the same config read.

Direct install

<script src="https://api.vitrinadev.com/tag.js?site=pk_live_xxx" async></script>

pk_live_xxx is the SAME publishable widget key window.vitrinaChat.publicKey would hold — copy it from Configuración › Conexiones › Web chat, same as for the plain widget.

You can also spell the site id as a data-site attribute instead of a query string:

<script src="https://api.vitrinadev.com/tag.js" data-site="pk_live_xxx" async></script>

Both forms work for a direct install. The ?site= query-string form is the one that matters for Google Tag Manager, below — GTM strips data-* attributes when it re-creates the tag, so a GTM-installed tag has to carry its site id in the URL itself. data-api-base / ?api= exist as an escape hatch that overrides the API host derived from the tag's own origin — you will not need either on a normal install.

Google Tag Manager install

For a dealer (or their agency) who installs everything through GTM and never touches page HTML directly:

  1. Open Vitrina → Configuración › Conexiones › Vitrina Ads and copy the ready-to-paste tag (GET /api/v1/ads/tracking/installers/gtm?site_id=<pk_…> returns it, already filled in with your site id — the API never asks you to build the snippet by hand).

  2. In GTM: Tags › New › Tag Configuration › Custom HTML, paste the snippet verbatim:

    <script src="https://api.vitrinadev.com/tag.js?site=pk_live_xxx" async></script>
  3. Trigger: All Pages.

  4. Name the tag (Vitrina's payload suggests one — e.g. "Vitrina — Asistente + Ads (tag único)") and Publish the container.

  5. Do not also install widget.js, and do not paste the measurement provider's own tracker snippet separately — this one tag is both.

Verify it worked with GTM's Preview mode: the assistant's launcher bubble should appear, and a tracker <script> from the measurement provider (the collector your key resolves to) should load in the Network panel unless the page denies consent (see next section).

Consent

The assistant half boots regardless of consent — talking to a visitor is not tracking them. The attribution tracker half loads by default: a site in Chile does not need a cookie banner, so with no consent tool installed the tracker simply runs. It holds back only when the page explicitly denies tracking, through either signal:

  • Google Consent Mode v2 — if your CMP already pushes the standard consent default / update entries onto window.dataLayer, the tag reads them; nothing else to wire up. ad_storage or analytics_storage set to 'denied' blocks the tracker.

  • window.__vitrinaConsent — a generic hook for a hand-rolled banner or a CMP that does not speak Consent Mode: set it to false (or a function returning false) to block, true to allow, at any point (before or after the tag loads).

    <script>
      // e.g. from your own "Rechazar cookies" button:
      window.__vitrinaConsent = false;
    </script>

The rule, in full:

| Page state | Tracker | | --- | --- | | No consent signal at all | loads | | Consent granted (either signal) | loads | | Consent explicitly denied | does not load; loads later if it becomes granted | | dataLayer present but no consent default set yet | waits about 1.5 s for one; loads if none is set, stays blocked if a denial arrives |

__vitrinaConsent takes priority over Consent Mode when both are present.

What the combined tag needs from Vitrina

  • #1605 — GET /api/v1/ads/tracking/key and GET /api/v1/ads/tracking/ installers/gtm (vitrina-app), admin-scoped, gated behind the Vitrina Ads entitlement.
  • #1610 — GET /api/v1/public/sites/{siteId}/tag-config (vitrina-app), public and unauthenticated — {siteId} IS the credential (this package's own pk_…). Answers { assistant, tracking }; tracking is null for a tenant without the Ads entitlement, and the assistant still boots.

Configuration options

| Option | Type | Required | Default | Description | | ---------------- | ----------------------------- | -------- | ------------------ | --------------------------------------------------------------------------- | | publicKey | string | yes | — | Publishable widget key (pk_…), origin-locked. Safe to ship in page source. | | apiBaseUrl | string | yes | — | Vitrina API base, e.g. https://<host>/api/v1. Trailing slash is trimmed. | | vehicleId | string | no | null | Pre-attach the inquiry to a vehicle (the id from /stock). | | vehicleLabel | string | no | null | Display title for vehicleId, e.g. Toyota Yaris 2021. Shown on the booking summary only when BOTH are set. | | locale | 'es' \| 'en' | no | the business's | Widget chrome language. Unset = the business's language from Vitrina; the visitor's browser decides only when the business offers several languages. Falls back to es. | | theme.accent | string (CSS color) | no | #111827 | Brand accent for the launcher + inbound bubbles. Sanitized; bad values fall back. | | theme.position | 'br' \| 'bl' | no | 'br' | Launcher corner: bottom-right or bottom-left. | | theme.logoUrl | string (http/https URL) | no | — | Older spelling of logoUrl below. Still honoured; the top-level one wins. | | logoUrl | string (http/https URL) | no | — | Logo in the panel header, ~22px tall, never cropped. Non-http(s) URLs are ignored — no logo, never a broken image. | | logoOnDarkUrl | string (http/https URL) | no | — | Logo variant for dark grounds (also theme.logoOnDarkUrl). Drawn instead of logoUrl on the accent header, and on the surface headers under prefers-color-scheme: dark. CSS picks the variant. Unset ⇒ logoUrl everywhere. | | font | WidgetFont | no | 'system' | Typeface for the whole widget. See Fonts below. Unknown values fall back to system. | | bookingLabel | string | no | localized copy | What the booking chip says, verbatim — e.g. Agendar demo. Trimmed; blank or over 40 chars falls back to the built-in copy. Only visible when the tenant has booking on. | | welcomeMessage | string | no | localized greeting | Greeting shown before the visitor sends the first message. | | home | { enabled?, title?, subtitle?, cards?, prompts? } | no | off | The Home tab. See Home and Help tabs below. Off unless enabled is true. | | home.cards | { buy?, sell?, search? } | no | all off | The automotive quick actions. See Quick actions below. Each card is off unless its key is explicitly true. | | home.prompts | ('properties' \| 'visit' \| 'list_property' \| 'products' \| 'order_status')[] | no | none | Prompt cards (real estate, store), in order. See Prompt cards below. Unknown ids and repeats are dropped. | | help | { enabled?, faqs? } | no | off | The Help tab (FAQ accordion). Off unless enabled is true and at least one usable question survives. | | team | Array<{ name, avatarUrl? }> | no | [] | Faces for the Home hero. Up to 5 accepted, first 3 drawn. Non-http(s) avatarUrl ⇒ initials. | | remoteConfig | boolean | no | true | Fetch appearance from Vitrina at load. false = fully self-contained. | | layout | 'bubble' \| 'sidebar' \| 'inline' | no | 'bubble' | Placement. See Sidebar mode below. | | preset | 'default' \| 'cream' | no | 'default' | Surface palette. cream = the Vitrina landing tokens, DM Sans, ember #AD4E00. | | agent | { name, label, status?, avatarUrl? } | no | none | The named assistant. Needs BOTH name and label (the AI disclosure) or it is ignored. | | chips | Array<{ label, message?, action? }> | no | [] | Up to 4 suggested questions. action: message (default), demo, booking. | | autoOpen | WidgetAutoOpenConfig | no | off | Proactive open. See Auto-open below. | | whatsapp | { number, text? } | no | none | One-tap continuation on WhatsApp with the conversation's reference code. | | cta | { label } | no | none | Full-width pinned button above the composer (sidebar/inline). | | demoWorld | string | no | none | Name of the fictional business demo mode plays, e.g. Automotora Demo. | | demoKind | 'automotora' \| 'clinica' | no | read off demoWorld | Which world the server plays (demo_world on each demo message). | | pageContext | Record<string, string> | no | {} | Page facts sent with each message (page_context), ≤ 8 snake_case keys. | | pricePolicy | { mode: 'first_ask' \| 'evaluation_only' \| 'never' \| 'script'; script?: string } | no | none | How the assistant answers price questions; sent as price_policy with each message (the server honours it only on the demo clinic's key). Unknown mode or script without text is dropped; script is trimmed and cut to 400 chars. Inline only. | | freshConversation | boolean | no | false | Forget this key's stored visitor at init so a NEW conversation starts with no history. Restart a chat with destroy() + init({ ...same, freshConversation: true }). | | launcherStyle | 'tab' \| 'bubble' \| 'sidetab' | no | 'tab' | Sidebar mode, desktop launcher. sidetab (0.18.0): a vertical rectangle on the right-centre edge with the portrait and a chat icon, no text; the ask is a tooltip and the aria-label (which carries the AI label). | | typingOnSend | boolean | no | false | Show the typing dots from the accepted send until a reply lands; «Sigue escribiendo…» after 12 s, gone after 60 s. Off by default: where a person answers later, dots would promise a reply that is not coming. | | showPoweredBy | boolean | no | true | The small «Con tecnología de Vitrina» credit under the composer (a followed link to vitrinadev.com, inside the widget's open shadow root, so it is part of the host page's DOM). false removes it. | | autoOpen.requireScroll | boolean \| { px?: number; viewport?: number } | no | off | Auto-open only after the visitor scrolled at least px pixels or viewport window heights (true = half a window), once since load. A visitor who stays on the first screen never gets it. | | chips[].reply / chips[].next | string / WidgetChipConfig[] | no | none | Scripted chips (0.18.0): answered in the browser after a 600–900 ms typing beat, with next chips under the reply (up to 3 scripted replies in a row, 4 chips each). No server call. The taps ride the next typed message as page_context.chips_seen. action: 'link' + url navigates; action: 'compose' focuses the composer. | | anchor | 'auto' \| 'bottom-bar' | no | 'auto' | bottom-bar: on phones the launcher renders inside launcherSlot. | | launcherSlot | HTMLElement \| string | no | — | Element or selector for the bottom-bar launcher. | | container | HTMLElement \| string | no | — | Element or selector for layout: 'inline'. Missing ⇒ sidebar. |

Booking ("Agendar visita", or whatever you call it)

There is no option for this. Booking is server-gated per tenant: GET /widget/config answers bookingEnabled and nothing on the page can turn it on, because the booking routes 404 for a tenant that has it off. When it is on, a chip appears over the composer and opens a fecha → hora → datos → resumen overlay laid over the panel; the conversation underneath is never destroyed.

A confirmed booking is kept in localStorage under vtr:widget:<pk_>:bookings as a list of management tokens, which is how a returning visitor sees and cancels their own visit with no account. Those tokens are capabilities — the widget never renders one, never logs one, and drops any the server stops resolving.

The chip's words come from the tenant: set bookingLabel (in Vitrina, or inline) and the chip says exactly that — "Agendar demo", "Reservar hora", "Book a test drive". It is used verbatim, in whatever language it is written in, and a chrome-language change never rewrites it. The rest of the flow's copy is deliberately neutral ("Mis reservas", "Confirmar reserva") so it still reads correctly next to a label that is not about a visit.

Set vehicleLabel alongside vehicleId if you want the car named on the booking summary; without a label the widget shows no vehicle line at all rather than an empty card.

Clinics: the clinic booking flow

When the Vitrina workspace is a clinic with an active online-booking landing, GET /widget/config answers clinicBooking: { enabled: true } and the chip ("Reservar hora" by default) opens the clinic flow instead: servicio → profesional (o cualquiera) → fecha → hora → datos → resumen → listo. Services, prices, deposits, professionals and branding come from the landing, hours from the clinic's Vitrina agenda, and the booking goes through the same service as the hosted booking page, so the cita, the contact and the deposit hold are identical whichever one the patient used.

The booking carries the ad click: UTMs and fbclid/gclid from the URL the patient landed on (remembered for the tab, so booking from another page still counts) and the Vitrina tag's anonymous visitor id. No page URL, referrer or title is ever sent, and the widget loads no ad-platform tag.

A clinic with several landings picks one with landing:

window.vitrinaChat = { publicKey: 'pk_…', apiBaseUrl: '…', landing: 'convenio-isapre' };

The confirmation shows the code and where the booking stands. For a service with a deposit it shows the amount, the deadline and — when the clinic connected its Mercado Pago — a «Pagar con Mercado Pago» button for that booking's own checkout (new tab). The screen re-reads the booking while it is open and flips to «Confirmada» once the payment lands. «Cambiar hora» and «Cancelar hora» run right there, through the booking's manage token.

Abandoned bookings. The details step has an unchecked box: «Quiero recibir mensajes por WhatsApp sobre esta reserva». Once a phone is typed, the widget saves a booking draft (what was picked, the box, the ad click and this page's URL). A patient who ticked it and left gets one WhatsApp from the clinic whose link comes back to this same page with ?vt_draft=…; the widget opens itself at the draft, details filled in (or at that day's hours, if the hour went), and the booking completes the draft. The page must be on an origin the key allows.

A host page with its own booking button opens the calendar directly:

// true  → the visitor is looking at the calendar
// false → they got the conversation panel instead (tenant has booking off, or
//         GET /widget/config has not answered yet — in which case the calendar
//         still opens by itself once it does, unless the panel was closed)
const onCalendar = window.vitrinaChatInstance?.openBooking() ?? false;

The panel opens either way, because the conversation is the honest fallback: a button that promised an agenda never leaves the visitor on the page they were already on.

Message content is never parsed as HTML. A visitor's own text is written with textContent; a reply goes through a safe-subset markdown renderer that constructs DOM nodes and never produces an HTML string. There is no innerHTML in the package, so an injected <img onerror=…> in a reply is a text node, not an element — XSS-safe by construction rather than by escaping.

Home and Help tabs

By default the panel is one view — the conversation — exactly as it has always been. Turn on home and/or help and it grows a tab bar:

{
  "home": { "enabled": true, "title": "¡Hola! 👋", "subtitle": "¿Cómo te podemos ayudar?" },
  "help": {
    "enabled": true,
    "faqs": [
      { "q": "¿Cómo agendo una visita?", "a": "Desde el botón **Agendar visita** eliges día y hora." },
      { "q": "¿Cuáles son los horarios?", "a": "Lunes a sábado, 9:30 a 19:00." }
    ]
  },
  "team": [
    { "name": "María Fernández", "avatarUrl": "https://cdn.dealer.cl/maria.jpg" },
    { "name": "Pedro Soto" }
  ]
}

That is the shape GET /widget/config sends, so this is set in Vitrina and reaches installed widgets without anyone editing a page. The same keys work inline as per-site overrides, and they merge field-wise: an inline home.title beats the server's while the server's home.enabled still decides whether the tab exists. team is the exception and replaces the server's list wholesale — interleaving two rosters would produce a team that does not exist.

  • Home is a hero in your accent colour (logo, an overlapping stack of up to three faces, greeting) with cards floating over its fade: the last message when there is a conversation to come back to, Envíanos un mensaje always, and the booking card only for a tenant whose agenda is on — titled with bookingLabel, never with our copy. title and subtitle are optional; both fall back to localized defaults.
  • Help is an FAQ accordion. Answers are markdown (the same safe subset the chat renders — bold, italics, links, code, lists) and, like everything else in this package, become DOM nodes rather than an HTML string. Several answers can stand open at once, and one sticky button at the bottom drops the visitor into the composer.
  • Where the visitor lands. Home, unless replies were waiting behind a closed panel — then Messages, because they came back to read something.

Caps and failure modes, all of them per-item rather than all-or-nothing: title ≤ 80 chars, subtitle ≤ 120, up to 20 FAQs (q ≤ 200, a ≤ 2000), up to 5 team members (name ≤ 40). A malformed FAQ entry is dropped on its own and the rest still render; an over-long title falls back to the default instead of being truncated mid-sentence; an avatarUrl that is not an absolute http(s) URL becomes the member's initials on a stable colour, never a broken image.

The panel is 400×704 (up from 360×520) whether or not you use the tabs, and still goes fullscreen at ≤480px.

Prompt cards — ver propiedades, agendar una visita, publicar mi propiedad, ver productos, estado de mi pedido

One-tap conversation starters for real-estate workspaces (0.21.0) and stores (0.22.0). Each card opens Mensajes and sends a fixed visitor message through the composer's own send path (same honeypot, consent and Turnstile gates, same message event):

{ "home": { "enabled": true, "prompts": ["properties", "visit", "list_property"] } }

| id | Card (es) | Message sent (es) | Message sent (en) | | --------------- | ----------------------- | ------------------------------ | ------------------------------ | | properties | Ver propiedades | «Quiero ver propiedades» | "I'd like to see properties" | | visit | Agendar una visita | «Quiero agendar una visita» | "I'd like to book a visit" | | list_property | Publicar mi propiedad | «Quiero publicar mi propiedad» | "I'd like to list my property" | | products | Ver productos | «Quiero ver productos» | "I'd like to see products" | | order_status | Estado de mi pedido | «¿Cuál es el estado de mi pedido?» | "What's the status of my order?" |

They paint in the given order, after the chat and booking cards and before the automotive quick actions. The server sends home.prompts only when at least one is on; an inline home.prompts replaces the server's list wholesale (it is an array, like team). A config refresh repaints them in both directions.

Quick actions — comprar, vender, lo buscamos por ti

Three more Home cards, each opening a short form inside the panel rather than sending the visitor to a landing page:

{ "home": { "enabled": true, "cards": { "buy": true, "sell": true, "search": true } } }

Opt-in per card, and the bag merges field-wise like everything else under home: a page can pin one card on and still let Vitrina decide the other two. Which cards a vertical turns on by default is decided server-side.

  • Comprar un auto — nombre, teléfono, email (opcional), ¿qué auto buscas?, presupuesto (opcional), consent.
  • ¿No encuentras el auto que buscas? — the same shape plus año and comentarios.
  • Vender tu auto — four steps: Tu auto (patente, marca, modelo, año, kilómetros) → Detalles (versión, precio esperado, plazo de venta, región, comentarios) → Fotos (optional, up to 8, 10 MB each) → Tus datos (nombre, teléfono o email, consent).

Where each one lands is the design. Buy and search compose a message and send it down the ordinary chat pipeline with the visitor's name and phone attached — so it appears in the transcript, the dealer sees it in their inbox like any other conversation, and the reply arrives where the visitor is already looking. The overlay closes onto Mensajes; there is no separate "thanks" screen, because the conversation is the confirmation.

Vender posts a real intake to POST /widget/consignments (multipart, photos included) and earns a confirmation screen — a fresh row and a duplicate get the same one, because the dealer already having the car is our bookkeeping, not the visitor's problem. Photo caps are enforced in the browser and a rejected file is named rather than silently dropped; nothing is lost on a refusal.

A host page with its own button opens a form directly, with the same semantics as openBooking() (deferred intent included):

// true  → the visitor is looking at that form
// false → they got the conversation panel instead (that card is off, or
//         GET /widget/config has not answered yet — in which case the form
//         still opens by itself once it does, unless the panel was closed)
const onForm = window.vitrinaChatInstance?.openHomeAction('sell') ?? false;

The gate is the card, not the Home tab: a tenant whose panel opens straight into the conversation can still be driven to the intake from their own page.

Consent is recorded, not implied: the intake sends consent_granted, the host page URL (consent_source_url) and consent_text_version: "widget-0.9" together, because what matters is which text was shown.

Fonts

font picks the typeface for the entire widget. system (the default) loads nothing and uses the native stack. Any other value loads one Google Fonts stylesheet:

| Value | Family | | --------------- | ------------- | | system | native stack (default, no request) | | dmSans | DM Sans | | ibmPlexSans | IBM Plex Sans | | poppins | Poppins | | nunitoSans | Nunito Sans | | archivo | Archivo | | montserrat | Montserrat | | saira | Saira |

How it works, and why it is not all inside the Shadow DOM. A shadow root scopes selectors, but @font-face is not a selector — browsers only match font-face rules declared in the document's font source, so a face declared inside a shadow stylesheet is silently ignored. The widget therefore appends a single

<link rel="stylesheet" data-vitrina-font="dmSans"
      href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap">

to your <head> — once per family, idempotent across re-inits and across a destroy()/init() cycle — and applies the family inside the shadow styles.

It cannot break your page. The stack it applies always ends in the same native fonts the widget has always used, so if the stylesheet is blocked by your CSP, 404s, or never arrives because the visitor is offline, the widget simply renders in the fallback stack. Nothing is deferred on the font, and Google's display=swap means text paints immediately either way.

If your site sends a strict Content-Security-Policy and you choose a non-system font, allow the two Google Fonts origins:

style-src  https://api.vitrinadev.com https://fonts.googleapis.com;
font-src   https://fonts.gstatic.com;

You do not need either directive when font is system.

Sidebar mode

layout: 'sidebar' turns the floating bubble into a right-docked assistant:

  • Desktop: a 400 px panel that overlays the page (it never pushes the layout) and collapses to a side tab with the assistant's portrait.
  • Phones (≤ 768 px): the panel only goes full screen when the visitor taps. Auto-open shows a peek card instead (greeting in two lines, the chips, a ×), and the card hides while any field on the page has focus or the keyboard is open, then comes back.
  • Disclosure: the portrait is always shown with agent.label beside it; below 40 px (side tab, launcher, peek card) it carries an «IA» badge. With no agent.avatarUrl a neutral silhouette is drawn. Under 40 px the portrait is exposed as an image named <name>, <label in lower case> (Tomás: «Tomás, asistente IA de Vitrina»).
  • anchor: 'bottom-bar': a page with its own sticky bar gives the widget a slot (launcherSlot). The launcher renders there on phones, and the peek card sits above the bar (mark the bar data-vtr-bar).
  • layout: 'inline': the same chat inside container, already open.
<script>
  window.vitrinaChat = {
    publicKey: 'pk_live_xxx',
    apiBaseUrl: 'https://api.vitrinadev.com/api/v1',
    layout: 'sidebar', preset: 'cream', anchor: 'bottom-bar', launcherSlot: '#tomas-slot',
    agent: { name: 'Tomás', label: 'Asistente IA de Vitrina', status: 'Responde al instante' },
    welcomeMessage: '¡Hola! Soy Tomás, el asistente IA de Vitrina. ¿En qué te ayudo?',
    chips: [{ label: '¿Cuánto cuesta?' }, { label: 'Pruébalo', action: 'demo' }],
    whatsapp: { number: '+56 9 XXXX XXXX', text: 'Hola Tomás, sigo la conversación de la web (ref {ref})' },
    cta: { label: 'Agendar demo' },
    autoOpen: { enabled: true, delaySeconds: 10, excludePaths: ['/agendar/*'] },
  };
</script>

Layout and chrome are decided once, at load, from the inline config plus the last cached server answer. A server-side change to them applies from the next pageview, never by re-mounting a widget the visitor may be typing into.

Auto-open

autoOpen: { enabled, delaySeconds = 10, mobileDelaySeconds, desktop = 'panel' | 'teaser' | 'off', mobile = 'teaser' | 'off', excludePaths, variant }

  • Counts from page load, so a lazily loaded widget opens on time.
  • Once per browser session: never again after it fired, after the visitor opened the widget themselves, or after any dismiss.
  • Never over a busy page. When it is due while a field is focused, a video with sound plays, or a <dialog open> / [aria-modal="true"] / [data-vtr-busy] element is on the page (mark your booking sheet), it waits for the page to be quiet and gives up after 60 s.
  • A phone never gets the full panel on its own; mobile: 'panel' is read as teaser.
  • variant is echoed on the autoopen event, for experiments.

The JavaScript API and events

init() (or window.vitrinaChatApi with the loader) returns: open(), close(), hide(), show(), prefill(text), send(text), enterDemo(), exitDemo(), openBooking(), openHomeAction(kind), setVehicle(id, label), whatsappUrl(), on(event, handler) (returns the unsubscribe) and destroy().

Events: open {source}, close {source}, hide {}, show {restored}, autoopen {mode, device, variant}, dismiss {surface}, chip {index, label, action}, message {clientMessageId}, handoff_whatsapp {ref}, demo_enter {entry, world}, demo_turn {turns}, demo_exit {reason, turns}, and cta {source}. The pinned CTA (and a booking chip) opens the in-widget agenda when the tenant has it on; otherwise it only emits cta, and the host opens its own booking sheet.

Demo mode (D60) frames the panel («Modo demo», the demoWorld mark, tinted replies, «Salir del modo demo») and sends mode: 'demo' with each message.

WhatsApp continuation

The link is a plain wa.me anchor (no window.open, so in-app browsers allow it): https://wa.me/<number>?text=<text with (ref XXXXXX)>. The reference is derived from the web conversation's external id: FNV-1a 32-bit over its UTF-8 bytes, top 30 bits as 6 Crockford base32 characters (0-9A-HJKMNP-TV-Z). vitrina-app recomputes it to join the WhatsApp thread to the web one. Before the first message there is no conversation, so the text carries no ref.

Loading it lazily (boot.global.js)

A page that guards its LCP loads the 1 KB boot stub instead of the widget:

<script src="https://cdn.jsdelivr.net/npm/@vitrina/[email protected]/dist/boot.global.js"
        data-src="https://api.vitrinadev.com/widget.js" async></script>

It injects widget.js on the first interaction, at idle + 4 s, shortly before the auto-open timer, or on the first API call. Until then window.vitrinaChatApi queues calls. Code that runs before the stub can queue directly: (window.vitrinaChatQueue ||= []).push(['on', ['cta', openSheet]]). pnpm --filter @vitrina/widget size prints the gzip report against budgets.

Where appearance comes from

locale, theme.*, logoUrl, font, bookingLabel, welcomeMessage, home, help and team are resolved server-side from the dealer's own Vitrina settings and fetched once at load (GET /widget/config). That is what lets a dealer restyle their bubble from the admin UI and have already-installed widgets pick it up — within about a minute — instead of asking every site owner to re-paste a snippet.

Three things worth knowing:

  • Anything you set inline wins. A widget installed before this existed carries a full inline config and therefore renders exactly as it always has. Treat inline values as per-site overrides.
  • It fails open. A network error, an older API without the route, a malformed answer — the widget renders with your inline/default theme and works normally. The request is never on the critical path.
  • It is cached in localStorage (last known good) and by the browser (60s, ETag-revalidated), so repeat visits paint the right colours with no network wait. Pass remoteConfig: false to opt out of the fetch entirely.

Security

  • pk_ is public by design. It is safe to ship in page source: it only works on the dealer's allow-listed origins (origin-locked, Vitrina ADR 0033) and only grants stock:read + leads:intake + widget:chat. It is not a secret and carries no admin capability.
  • Origin lock + CORS. Requests only succeed from the domains configured for that key; the widget sends a fixed, minimal header set (Authorization, Content-Type, X-Vitrina-Visitor) and never credentials: 'include'.
  • AI kill-switch defaults OFF. With AI answers off, a visitor talks to a human via the dealer inbox; replies arrive over the same SSE→refetch path once the dealer enables AI later — no widget change needed.
  • Honeypot. The composer includes a hidden hp_website field (visually hidden off-screen, never display:none) that is always submitted — empty for a human, a spam signal when a bot fills it.

Reliability

Things the widget guarantees, because a dealer's chat failing quietly is worse than it failing loudly:

  • A visitor's message is never lost. The optimistic bubble is a real entry in the message list, not a DOM artifact, so no repaint can destroy it. A failed send leaves it on screen marked not sent, with an inline retry that re-uses the original client message id — so retrying a message that did land is idempotent rather than a double-post.
  • A failed history fetch repaints nothing. A 500 is distinguishable from an empty conversation, so an error can never blank the panel.
  • The connection tells the truth. Offline, reconnecting, sending and failed are separate signals with an explicit precedence, so a successful send can't wipe an offline notice that is still true.
  • Nothing thrown reaches the host page. Every transport method returns a typed outcome; init() throws only on a missing publicKey/apiBaseUrl, and the <script> loader catches even that.

Browser support

Evergreen browsers (Chrome/Edge, Firefox, Safari, and their mobile equivalents). The widget requires Shadow DOM, fetch with streaming ReadableStream bodies (for SSE), AbortController, and CSS custom properties — all baseline in browsers from ~2020 onward. No IE11 support.


Development

pnpm build       # tsup → dist/ (ESM library + IIFE loader + IIFE combined tag + .d.ts)
pnpm typecheck   # tsc --noEmit
pnpm test        # vitest (happy-dom)