# EchoSistema Template - implementation guide for agents

> **Normative** reference for AI agents and for anyone generating code on top of
> this template. Every line of code here is copyable **verbatim**: tag names,
> props, events and classes are contract.
>
> Document version: 2026-08-11 · Source: `app/components/`, `app/assets/css/main.css`
> Per-area guides: `/agents/<file>.md` (one per page of `/componentes`).

---

## 0. Rules that apply to every line you generate

1. **Never write a colour value.** No hex, no `rgb()`, no Tailwind colour
   (`text-blue-500`). Every colour comes from a token: `text-fg1`, `bg-panel`,
   `border-line`, `text-brand-link`, or the intent tokens (`--i`, `--i-soft`,
   `--i-text`) through `.es-i--*`.
2. **Two axes, never combined by hand.** The intent gives the colour
   (`.es-i--primary`, `.es-i--danger`, …) and the variant consumes it
   (`.es-btn--filled`, `.es-chip--soft`, …). Write `<echo-button
   variant="filled" intent="danger">`, never a colour class on the button.
3. **The `es-` prefix** on every class of the system. A derived site may rename
   the prefix, as long as it renames all of it.
4. **Native element first.** `<dialog>` for a modal, `<details>` for a collapse,
   the system's date picker for a date. The keyboard and the semantics come for
   free.
5. **Visible text always through `t()`.** Tag names, props, classes and code
   samples stay literal. Three languages in lockstep:
   `i18n/locales/{es,pt-BR,en}.json`. Careful: `@` and `<tag>` inside a message
   break the vue-i18n compiler - write `{'@'}` and name the tag without the
   angle brackets.
6. **A component does not fetch.** It takes `props` and emits events; whoever
   knows the URL, the token and the route is the page. That holds for the
   calendar, the bars, the account forms and the filters.
7. **Comments in English, UI in Portuguese/Spanish/English.** This repository is
   commented in English and the comments explain **why**, not what.
8. **Accessibility is not optional.** An icon alone inside a button needs an
   `aria-label`. A group of fields needs `<fieldset>` + `<legend>`. State needs
   `aria-current` / `aria-pressed` / `aria-invalid`.

### Tokens you will use constantly

| Token / class | What it is |
| --- | --- |
| `text-fg1` `text-fg2` `text-fg3` `text-fg4` | Text, strongest to faintest |
| `bg-page` `bg-panel` `bg-chip` `bg-nav` `bg-footer` | Surfaces |
| `border-line` `border-nav-bd` `border-footer-bd` | Lines |
| `text-brand-link` `bg-brand-1` `bg-brand-2` `bg-brand-deep` | Brand |
| `.es-card` | The flat card (dense lists) |
| `.es-glass` `.es-glass-strong` | Glass (masthead, menus, CTA) |
| `.es-shell` | The content box; `.es-shell--full` releases the ceiling |
| `.es-menu` `.es-menu-item` `.es-menu-sep` | Dropdown menu |
| `.es-table` `.es-table--dense` | Table |
| `.es-code` `.es-tag` `.es-lead` `.es-h2` | Utility typography |
| `.es-scroll` | Scrolling with the themed scrollbar |
| `.es-nav-link` | Navigation link with its active state |
| `.es-field-msg` `.is-valid` `.is-invalid` `.is-neutral` `.is-optional` | Fields |

### Intents (the colour axis)

```
primary · secondary · success · danger · warning · info · caution ·
steel-blue · navy-blue · dark
```

### Content width

`data-width` on the root: `compact` (90rem) · `extended` (110rem, **what the
template ships with**) · `full` (no ceiling). The deployment's default comes
from `.env`:

```bash
NUXT_TEMPLATE_THEME_WIDTH=extended   # this site (the identifier wins)
NUXT_THEME_WIDTH=extended            # every site sharing the env file
```

It is baked at build time (it travels in the pre-paint script that prevents the
layout jump).

### Spacing scale

`es-margin-*` and `es-padding-*`, from `05` to `10`, on an 8px step:

```
05 = 4px   1 = 8px    2 = 16px   3 = 24px   4 = 32px   5 = 40px
 6 = 48px  7 = 56px   8 = 64px   9 = 72px  10 = 80px
```

Each value comes in seven shapes:

| Class | Property |
| --- | --- |
| `es-margin-3` | `margin` |
| `es-margin-x-3` | `margin-inline` |
| `es-margin-y-3` | `margin-block` |
| `es-margin-t-3` | `margin-top` |
| `es-margin-b-3` | `margin-bottom` |
| `es-margin-l-3` | `margin-left` |
| `es-margin-r-3` | `margin-right` |

The same seven for `es-padding-*`. The AXES are logical (`inline`/`block`) and
the SIDES are physical, deliberately: `x` and `y` hold in any writing direction,
while `l` and `r` say left and right and that is what they do - mapping `l` to
`inline-start` would have a class called "left" pushing from the right in
Arabic. In a layout that has to flip, use the axes.

```vue
<div class="es-padding-3">…</div>
<div class="es-margin-y-2">…</div>
<div class="es-margin-t-4">…</div>
```

### Entrance animations

Seven classes in the spirit of animate.style, written on this system's tokens
instead of pulled in as a dependency (the library is 90 animations and ~70 kB
for the four a site uses, and it brings its own durations and curves):

```
es-anim-fade · es-anim-fade-up · es-anim-fade-down · es-anim-fade-start ·
es-anim-fade-end · es-anim-zoom · es-anim-pop
es-anim-delay-1 … es-anim-delay-5   (60ms apart, to stagger a list)
```

```vue
<article class="es-card es-anim-fade-up es-anim-delay-2">…</article>
```

`start`/`end` rather than `left`/`right`, so a right-to-left edition enters from
the correct side. With reduced motion on, the element is born in its final
state: the information stays, the movement does not.

---

## 1. Actions

```vue
<echo-button variant="filled" intent="primary" size="sm" @click="save()">Save</echo-button>
<echo-button variant="outline" intent="danger" :aria-label="t('remove')"><icon-trash class="size-4" /></echo-button>
```

- `variant`: `filled | gradient | outline | glass | ghost`
- `intent`: one of the ten intents
- `size`: `sm | lg` (no prop = medium)
- `type`: `button` (default) `| submit | reset`

An action that **navigates** is an anchor, not a button:
`<NuxtLink class="es-btn es-btn--filled es-i--primary no-underline">`.

Labels:

```vue
<echo-chip variant="soft" intent="info">filter</echo-chip>    <!-- clickable -->
<echo-badge variant="soft" intent="success">active</echo-badge> <!-- static -->
<echo-pill variant="soft" intent="warning">12</echo-pill>       <!-- a count -->
```

Navigation:

```vue
<echo-tabs :tabs="TABS"><template #overview>…</template></echo-tabs>
<echo-pagination v-model="page" :total="12" size="sm" intent="success" />
<echo-breadcrumb :items="CRUMBS" chevron />
<echo-dropdown :label="t('menu')" start>
  <template #trigger><span class="es-btn">…</span></template>
  <template #default="{ close }"><li><button class="es-menu-item" @click="close()">…</button></li></template>
</echo-dropdown>
```

---

## 2. Forms

```vue
<echo-field
  v-slot="{ id, fieldName, describedBy }"
  :label="t('forms.fields.email')"
  name="email"
  required
  state="is-invalid"
  :message="t('forms.errors.email')"
  hint="00000-000"
>
  <echo-input :id="id" v-model="email" :name="fieldName" :aria-describedby="describedBy" type="email" autocomplete="email" />
</echo-field>
```

`<echo-field>` resolves the wiring: the label's `for`, the message's
`aria-describedby` and `aria-invalid` on error. Use it wherever there is a
label.

Controls: `echo-input` `echo-textarea` `echo-select` `echo-search-select`
`echo-tags-input` `echo-checkbox` `echo-radio` `echo-switch` `echo-slider`
`echo-rating` `echo-file-button` `echo-dropzone` `echo-date-picker`
`echo-phone-input`.

Composition rules (they hold for any form you generate):

- two columns **only** for short fields that belong together;
- mark what is required (or, if nearly everything is, mark what is optional);
- validate **on submit**, never on every keystroke;
- actions at the end, aligned to the end, with **one** filled button;
- each block in a `<fieldset>` with its `<legend>`;
- the right `autocomplete` on every field.

---

## 3. Feedback

```vue
<echo-alert tone="danger" :title="t('errors.title')">…</echo-alert>
<echo-alert tone="success" :title="t('saved.title')" closeable :timeout="8000">…</echo-alert>
<echo-modal ref="modal" title-id="x-title" size="md" intent="warning">
  <template #title>…</template>
  …
  <template #footer><echo-button type="submit" variant="filled" intent="primary">…</echo-button></template>
</echo-modal>
<echo-drawer ref="drawer" title-id="y-title" start>…</echo-drawer>
<echo-block :loading="pending" :label="t('loading')">…</echo-block>
<echo-loader :loading="loading">…the whole page…</echo-loader>
<echo-skeleton :lines="3" /> · <echo-spinner size="sm" dots /> · <echo-progress :value="64" :max="100" />
```

`<echo-block>` veils a region; `<echo-loader>` veils the whole PAGE (brand in
the middle, slot `inert`, 5s watchdog, spares the backoffice's side menu and top
bar). Its `loading` comes from the data - `useAwaitingFirstRead(() =>
Boolean(data.value) || Boolean(error.value))` - never from `status ===
'pending'`, or server and client disagree on hydration.

`open()` / `close()` come from the `ref`. Toasts:
`useToast().push({ tone, title, message, timeout })`.

These are the system's NOTICE SURFACES, and they share one recipe: a ring in
`color-mix(--i 30%, --border)`, a tint at 8% and the wedge at the bottom-right
corner. Anything else that has to say something is built from the same recipe,
never from a new one.

---

## 4. Content and media

```vue
<echo-card :title="t('team')" :subtitle="t('who')" :loading="pending">…</echo-card>
<echo-data-table :rows="ROWS" :columns="COLS" />
<echo-avatar :src="url" name="Ada Lovelace" size="lg" />
<echo-gallery :items="IMAGES" /> · <echo-tree :nodes="NODES" /> · <echo-timeline>…</echo-timeline>
<echo-chart :option="option" :summary="t('chartSummary')" height="280px" />
<echo-map :lat="-25.5" :lng="-54.6" :zoom="12" :markers="PINS" :summary="t('…')" />
<echo-map-google :lat="-25.5" :lng="-54.6" :zoom="12" :markers="PINS" :summary="t('…')" />
<echo-carousel :label="t('slides')" loop autoplay>…</echo-carousel>
<echo-editor v-model="html" :label="t('body')" />
<echo-qr-code value="https://example.com" :size="180" /> · <echo-barcode value="ECHO-0001" format="CODE128" />
```

The chart, the map, the carousel and the editor bring heavy dependencies
(echarts, leaflet, swiper, tiptap): only include the one the page actually uses.

**The two maps.** `<echo-map>` is Leaflet + OpenStreetMap: it costs nothing,
needs no account and is the default. `<echo-map-google>` is the Google API, it
needs the browser key `NUXT_PUBLIC_GOOGLE_PLACE_API_KEY` and, without it, it
renders the explanation instead of a grey rectangle. Both follow the theme:
Google because its palette is built from the tokens at runtime (switching theme
or palette repaints the map), and OSM because its tiles go through a filter in
the dark theme, applied to `.leaflet-tile-pane` alone so markers and popups are
not inverted with them.

---

## 5. Calendar

```vue
<echo-calendar
  v-model:view="view"          <!-- month | week | day | list -->
  v-model:date="focusDay"      <!-- YYYY-MM-DD, the day in focus -->
  :events="visible"
  :week-start="1"              <!-- 0 Sunday, 1 Monday -->
  :max-per-day="3"
  :day-start="7" :day-end="22"
  @create="openDrawer"         <!-- click on empty space -> 'YYYY-MM-DD' -->
  @open="editEvent"            <!-- click on an entry -> EchoCalendarEvent -->
/>

<echo-calendar-mini v-model="focusDay" :events="visible" />
```

The event (`#shared/types/echo`):

```ts
interface EchoCalendarEvent {
  id: string
  title: string
  start: string            // 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:mm', LOCAL
  end?: string             // last day, inclusive
  allDay?: boolean
  label?: string           // the category, the filter's key
  intent?: EchoIntent      // the colour
  mode?: 'in-person' | 'video' | 'phone' | 'messenger'   // HOW it happens
  channel?: string         // 'meet' | 'zoom' | 'whatsapp' | 'telegram' | 'signal' | …
  location?: string        // in person
  contact?: string         // number (phone) or account (messenger)
  url?: string             // link to join (video)
  description?: string
}
```

An absent `mode` is `in-person` - that is what an entry with an address and
nothing else always meant. Each mode has ITS field, and only that one travels:
a link for video, a number for phone, an account for messenger, an address for
in person. The mode's icon is drawn in all four views, including the 11px chip
of the month view, and the list gains an `<a target="_blank">` "join" when there
is a `url`. `channel` is a free string: its label comes from
`calendar.channels.<channel>` when the system knows it and verbatim when it does
not.

The arithmetic lives in `app/utils/calendar.ts` (auto-imported): `isoDay`,
`parseDay`, `addDays`, `addMonths`, `startOfWeek`, `monthGrid`, `weekDays`,
`eventsOfDay`, `eventOnDay`, `minutesOfDay`. All in **local** time -
`toISOString()` reads UTC and moves half the events a day back west of
Greenwich.

The calendar does **not** fetch, does **not** filter and does **not** create:
the page does all three and passes `:events` already filtered.

---

## 6. Account (authentication)

Two shapes, **one** set of fields:

```vue
<!-- The fields, with no frame. `form-id` is required: the submit button lives
     outside the <form> and reaches it through the `form` attribute. -->
<auth-login-form :form-id="id" :pending="pending" :error="failure" bare
                 @submit="signIn" @forgot="…" @register="…" />
<auth-register-form :form-id="id" @submit="register" @login="…" />
<auth-forgot-password-form :form-id="id" :sent="sent" @submit="recover" @login="…" />
<auth-update-password-form :form-id="id" require-current @submit="changePassword" />

<!-- Frame A: a dialog over the page the visitor was already reading -->
<auth-login-modal ref="modal" :pending :error @submit="signIn" @forgot="…" @register="…" />

<!-- Frame B: a whole page, `auth` layout (no masthead) -->
<auth-page-shell :title="t('auth.login.title')" :image="COVER" image-side="end"
                 :cover-title="t('…')" :cover-text="t('…')" size="md">
  <auth-login-form :form-id="id" @submit="signIn" />
  <template #actions>…</template>
  <template #footer>…</template>
</auth-page-shell>
```

Payloads (in the API's vocabulary, not the file's variables):

| Component | `submit` |
| --- | --- |
| login | `email`, `password`, `remember` |
| register | `name`, `email`, `gender` (m\|f\|o), `birth_date` (yyyy-mm-dd), `password`, `password_confirmation`, `terms` (1\|0) |
| forgot | `email` |
| update | `current_password`, `password`, `password_confirmation` |
| lock | `password` |

Ready routes: `/entrar`, `/crear-cuenta`, `/recuperar-clave`, `/nueva-clave`
(slug translated per language in `nuxt.config.ts` -> `i18n.pages`).

### The session is a COOKIE you never touch

The platform API is a Backend-For-Frontend. Signing in sets
`echosistema_bff_session`, `HttpOnly`, and the browser replays it by itself.

- **There is no token and no `jwt`.** Both fields were removed from the auth
  responses; there is nothing in the body to read, store or send back.
- **Never build `Authorization: Bearer`** from a browser. Presenting a cookie
  AND a bearer on the same request is rejected outright, not resolved by
  precedence.
- **Every call needs `credentials: 'include'`**, the login included - that is
  how the browser accepts the `Set-Cookie`. `useApi()` already does it; a raw
  `$fetch` to the API does not, which is why calls go through `useApi()`.
- **Mutating calls need `X-XSRF-TOKEN`**, echoing the readable `XSRF-TOKEN`
  cookie. Without it a `POST`/`PUT`/`PATCH`/`DELETE` carrying the session is
  refused with `419`. `useApi()` adds it and retries once on `419`.
- **Permissions are a second call.** The login carries the role's SCOPE only;
  `GET /api/v1/auth/permissions` is the authorization source. The strings are
  `action.subject`, and the check is an exact match OR the `{action}.all`
  wildcard - which is exactly what `can(subject, action)` does.
- **Do not poll to stay signed in.** The session has a seven-day ABSOLUTE TTL
  that nothing extends; `keep-alive` refreshes the Keycloak JWT, not the
  session, and a failed one costs nothing. What ends a session on screen is a
  `401` from a real request, which raises the lock.

`echosistema_session` (readable, no credential) exists only so the `/backoffice`
guard and SSR know WHO claims to be signed in. It decides what to DRAW; the API
decides what is allowed, on every request. After a reload it is corrected from
`GET /api/v1/auth/me`, which is the source of truth for the user.

### Carrying the session to another site of the group

A cookie belongs to a DOMAIN, so a link from this site to a sibling one
(`alquila.online`, `echointel.online`) would land the reader there as a
stranger. The API's answer is a **flash token**: one shot, 60 seconds, the right
to open a session on the other origin.

```vue
<script setup>
const { link } = useHandoff()

// Minted here, spent there. Returns the URL untouched when nobody is signed in.
async function goToSibling() {
  window.location.href = await link('https://alquila.online/panel')
}
</script>
```

Receiving is automatic: a site built on this template accepts a
`…/rota#flash=<token>` link, opens the session and scrubs the token from the
address bar. Two rules if you write this by hand instead:

- **The fragment, never the query string.** A fragment is not sent to the
  server, so it stays out of access logs and out of `Referer`. A query string
  is in all of them, and a credential in a log is a credential leaked.
- **Scrub it as soon as it is spent.** The token dies on first read; leaving it
  in the URL only leaves something that looks like a credential and is not one.

---

## 7. Chrome: bars, footers, rails and switchers

```vue
<masthead-ecommerce :links="LINKS" :departments="DEPTS" :cart-count="3" :wishlist-count="7"
                    name="Ada" :avatar="AVATAR" lens wide embedded @search="goToListing" />
<masthead-real-estate v-model:scope="scope" :links="LINKS" :departments="TYPES" :scopes="SCOPES"
                      :account-links="ACCOUNT" :wishlist-count="4" wishlist-menu @search="goToListing" />
<masthead-learn :links="LINKS" :course="course" :done="17" :total="24" name="Ada" @search="…" />
<masthead-help-desk :links="LINKS" :status="status" status-tone="success" :ticket-label="label" @search="…" />
<masthead-landing :links="SECTIONS" :cta-label="cta" cta-to="/crear-cuenta" :sign-in-label="…" floating />

<footer-ecommerce :columns="COLUMNS" :legal="LEGAL" phone="+595…" email="…" :note="…" />
<footer-real-estate :columns="COLUMNS" :legal="LEGAL" :contacts="CONTACTS" :rates="RATES"
                    :subscribe="SUBSCRIBE" :image="BG" @subscribe="send" />
<footer-learn :columns="COLUMNS" :legal="LEGAL" :cta-title="…" :cta-text="…" :cta-label="…" cta-to="/…" />
<footer-help-desk :columns="COLUMNS" :status="…" :hours="…" phone="…" email="…" />
<footer-landing :links="LINKS" :tagline="…" />

<sidebar-panel v-model:collapsed="collapsed" :groups="GROUPS" fixed />
<sidebar-docs :groups="NAV" content-selector="#guide" initial="intro" />
<sidebar-filters v-model="picked" :groups="FILTERS" :title="t('chrome.filters.label')" />

<element-theme-switch />                      <!-- flat is the default -->
<element-language-switch compact />
<element-currency-switch />
<element-switch-dropdown :label="t('sort')" start up menu-class="min-w-52">
  <template #trigger>…</template>
  <template #default="{ choose }">…</template>
</element-switch-dropdown>
```

Props shared by the five bars: `links`, `wide`, `embedded` (drops the `sticky`,
for mounting inside a page), `lens` (the icon controls with the round capsule;
flat is the default), `name`/`avatar` (the signed-in person's portrait) and the
`search` event.

Types in `app/data/chrome.ts`: `ChromeLink`, `ChromeColumn`, `ChromeDepartment`,
`SidebarItem`, `SidebarGroup`.

A sidebar item takes its icon as a **component** (`icon?: Component`), not as a
name: resolving by name would pull every component of the site into the same
chunk.

The switch dropdown's menu is teleported to the body and positioned `fixed`: an
absolutely positioned menu is clipped by any ancestor with `overflow: hidden`
and covered by any stacking context, and a masthead creates one.

---

## 8. Composables

| Composable | What it returns |
| --- | --- |
| `useTheme()` | `mode`, `theme`, `select`, `cycle`, `options` |
| `useLang()` | `active`, `options`, `select` (navigates; the URL owns the locale) |
| `useLanguages()` | the platform's catalogue (`GET /api/v1/languages`) |
| `useCurrency()` | `currencies`, `active`, `select`, `format` |
| `useCurrencies()` | the catalogue (`GET /api/v1/currencies`) |
| `useContentWidth()` | `width`, `widths`, `select`, `cycle` |
| `usePalette()` | `palette`, `palettes`, `select` |
| `useAuth()` | `user`, `roles`, `permissions`, `isAuthenticated`, `locked`, `signIn`, `signOut`, `setAvatar`, `can(subject, action)` |
| `useAuthSession()` | the raw state - the readable cookie, the identity, the lock, `expire()`. No API, so a middleware may use it |
| `useHandoff()` | `link(url)` to carry the session to a sibling site, `consume()` to accept one |
| `useApi()` | `api()`, carrying the public key, the language, `credentials: 'include'` and the CSRF header |
| `useToast()` | `push`, `clear`, `position` (`top`/`bottom`), `align` (`start`/`center`/`end`/`full`) |
| `usePersistedState()` | state that survives a reload, in the group's storage bag |
| `useBarSkin(lens)` | `iconButton`, `switchTrigger` - a bar's two skins |

---

## 9. The mistakes this document exists to prevent

- Writing `class="bg-white text-gray-800"` instead of `bg-panel text-fg1`.
- Repeating `mx-auto max-w-7xl px-4` instead of using `.es-shell` (the width
  preference then stops reaching that page).
- Calling the API from inside a presentational component.
- Reaching for `response.token` or `response.jwt` from the login. Neither field
  exists any more; the session is a cookie the browser handles - see section 6.
- Reaching the API with a bare `$fetch` instead of `useApi()`: it goes out with
  no `credentials: 'include'`, no public key and no CSRF header, so it is
  anonymous at best and a `419` at worst.
- Putting a raw `@` or `<tag>` inside an i18n message.
- A button with an icon alone and no `aria-label`.
- Two filled actions side by side in a form's footer.
- An `<echo-modal>` for a screen nobody opened over anything (a sign-in that is
  a direct destination wants `<auth-page-shell>`).
