DESIGN.md
docs/content/3.ai/designmd.md in the eds-next repository, and DESIGN.md at the repository root is only a pointer to it, so there is a single copy to keep current. Product copy examples in the tone of voice section stay in Brazilian Portuguese on purpose: they are the artifact being documented, not prose.Persistent context for any AI agent (Claude, Cursor, Copilot and so on) that generates or edits Mercado Eletrônico (ME) interfaces. It covers the product: color, spacing, typography, Vue and React tokens, tone of voice and the logo. It does not cover institutional marketing material (brand book, presentations), only the use of the logo inside the product (section 12).
Status: public, no authentication. Canonical location: eletro.design/ai/designmd. Maintained by the Eletro team (see section 0.2). Last verified against the code: 2026-08-21 (@nuxt/ui 4.10.0, tailwindcss 4.3.3, @mercadoeletronico/eds-next-react 1.1.1). An outdated file is worse than no file, because it leads the agent into error instead of flagging the gap, so treat every section marked "known gap" as exactly that, a real gap, not a licence to invent a value.
0. How to use this file
- Re-read section 4 (Absolute rules) before writing any component, screen or snippet.
- Identify the target: EDSnext (Vue 3 / Nuxt UI v3), EDSlegacy (Vue 2) or EDSreact (
@mercadoeletronico/eds-next-react). Token naming, the dark mode mechanism and the way you style all differ between them. Section 2 has the full table, section 10 has the React-specific rules. - Never hardcode a color, spacing, radius or font value. Always a token, even though the name differs between Vue and React.
0.2 Who maintains this and how to contribute
The EDS product (the name did not change) is maintained by the Eletro team (previously called "EDS"/"EletroDS" as a team name, renamed in 2026). The design system is innersource: any team at ME can open an improvement MR on the repositories (eds-next, eds-next-react, me-toolkit), and reviewing those contributions is the Eletro team's responsibility. In practice: an agent producing a contribution from outside the Eletro team follows the same rules in this file and waits for an Eletro review before merging, rather than skipping review for not being on the team.
1. What the product is for
ME (Mercado Eletronico) is a technology company offering corporate procurement solutions. The end user of the product interface is mostly:
- A buyer or procurement professional inside a customer company, using the product daily for quotes, suppliers and approvals.
- A registered supplier, answering quotes and managing proposals.
- On internal screens, also the PO, designer and developer on the Eletro team, validating components.
Business context (inherited from the brand positioning, but relevant to UI prioritisation): the product has to read as expert, intelligent, partner and technological. It is not a consumer app, it is a data-dense working tool used by professionals throughout the workday. Which means: well-organised information density over empty minimalism, and clarity over strong visual personality.
2. Stack and scope
| System | Stack | Headless base (primitives) | Dark mode | Charts |
|---|---|---|---|---|
| EDSnext 1.0 (Vue, current) | Vue 3 / Nuxt UI v3 / Tailwind v4 | Reka UI (the Vue port of Radix UI, used internally by Nuxt UI v3) | Yes, always covered | shadcn-vue chart (Unovis/vue) |
| EDSlegacy (Vue 2) | Vue 2 | — (in-house library, pre Reka UI) | Not implemented. Never generate, mention or validate dark mode for EDSlegacy. | — |
| EDSreact (React) | React 19 / Next.js / shadcn/ui + Tailwind v4 / CVA. pnpm + Turborepo monorepo. Published package: @mercadoeletronico/eds-next-react. Repo: me-eds-next-react. | Radix UI (plus @base-ui/react in some components) | Yes, through next-themes (class="dark"), always covered | shadcn/ui chart (Recharts) |
Careful: EDSnext (Vue) and EDSreact (React) implement the same design intent with different token naming (
--ui-primaryin Vue versus--primaryin React, for example). That is deliberate: each stack follows the convention of its own base library (Nuxt UI v3 versus shadcn/ui). Never copy a token name from one stack to the other without checking the equivalence table in section 10.4.EDSnext and EDSreact share the same philosophy of accessible primitives: Reka UI is the official Vue port of Radix UI, kept at API and behaviour parity (ARIA roles, focus management, keyboard navigation, portal and collision detection). In practice that means:
- If the Nuxt UI v3 / Reka UI documentation is incomplete about an accessibility or interaction behaviour, the Radix UI documentation for the equivalent component is a reliable reference, since the API tends to mirror it.
- EDSlegacy is the exception: it uses neither Reka UI nor Radix, it is an in-house library that predates that migration. Do not assume behaviour parity between EDSlegacy and the two current stacks.
Component naming:
- Ticket title, Inception, Tech Guideline → full prefix (
MeSidebar,MeWidget,MeTable). - PDD or material for a non-technical audience → no prefix (
Sidebar,Widget,Table).
Public component documentation lives at eletro.design and ui.nuxt.com.
Storybook is not a documentation channel for EDS. Never document a component in it, never link to it, never propose it as the output of a task. To be precise about it: the React monorepo does contain a packages/storybook package (@mercadoeletronico/storybook, private, with dev and build scripts), but without a single story and without a mention in the repo's own AGENTS.md. It is idle scaffolding. Finding that package is not a licence to treat it as a convention or to write stories, and the Vue repo does not even have the scaffolding.
Charts: both stacks follow the shadcn chart pattern (copy-paste components with ChartContainer, ChartConfig and the --chart-1 to --chart-N tokens). In React, pnpm dlx shadcn@latest add chart (backed by Recharts). In Vue, the same components through shadcn-vue (backed by Unovis/vue). Never install another charting library (Chart.js, ApexCharts, Nivo, Highcharts and so on) and never build a chart from scratch without going through the shadcn / shadcn-vue CLI.
3. Visual Theme
The principles below apply to any stack (Vue or React). The token names quoted as examples (
--ui-*) use the Vue naming. For the React equivalent, see the table in section 10.4.
- Overall tone: utilitarian, dense, dependable. This is not a consumer brand, it is a B2B tool.
- Primary color leads, but stays contained: the brand blue (
--ui-primaryin Vue,--primaryin React) shows up in primary actions, links, focus and active navigation states. Do not paint large background blocks with the primary color as if it were a marketing piece (that is a brand book rule, not a product rule). - One verified exception: the Header bar.
Header.vueappliesbackground: var(--ui-primary)on desktop whenbrand.backgroundis not provided, andbackground: var(--ui-bg)on mobile. It is the only product surface that fills a large block with the primary color. Any other flat blue area is wrong.brand.backgroundexists as a white label escape hatch (aprimaryColor/secondaryColorgradient and images), controlled by the consuming app, not by the DS. - Neutral background:
--ui-bg/--backgroundas the base of the screen,--ui-bg-elevated/--cardfor cards and panels,--ui-bg-muted/--mutedfor a light hover. - Hierarchy through weight and text color, not through exaggerated size: a highlighted title, standard body, metadata in secondary text, placeholder and disabled in the dimmest tone available.
- Small to medium radius, never a pill on content elements (pill is only for badge, avatar and tag).
- Discreet shadow. No heavy Material Design style shadows (dramatic elevation). Full scale and rules in section 7.1.
- Motion: scale documented in section 3.1. In short,
150ms(the Tailwind default) for state feedback,200msfor movement,ease-outentering andease-inleaving. Never write an arbitrary duration or easing.
3.1 Motion
Taken from the compiled component themes (packages/ui/.nuxt/ui/*.ts) and from the keyframes.css in @nuxt/ui. This is not an invented scale: it is what the library components already do, now named so it can be followed on purpose instead of by accident.
| Category | Duration | Easing | Where it applies |
|---|---|---|---|
| State feedback | 150ms (default, no class needed) | cubic-bezier(0.4, 0, 0.2, 1) (default) | Color change: hover, active, focus, input border. transition-colors is enough, Tailwind already resolves both values |
| Movement | 200ms (duration-200) | ease-out | Transform, width, translate, rotate: the chevron rotating on open, sidebar width, the tabs indicator, the switch thumb, the progress bar, an image scaling on hover |
| Overlay entering | 200ms | ease-out | animate-[fade-in_200ms_ease-out] on modal, popover, dropdown |
| Overlay leaving | 200ms | ease-in | animate-[fade-out_200ms_ease-in]. It leaves with the easing inverted relative to entering, on purpose |
| Expand and collapse | 200ms | ease-out both ways | animate-[collapsible-down_200ms_ease-out] and collapsible-up, same pair for the accordion |
| Loading | continuous | linear | animate-spin (Spinner and the button loading icon) |
| Indeterminate and skeleton | continuous | — | animate-pulse, always behind motion-reduce: when it replaces real movement |
| Toast restacking | 100ms | — | The only use of duration-100 in the package. Do not reuse that value for anything else |
Rules of use:
- Do not write a duration outside that table.
duration-[220ms],duration-300,duration-75and any arbitrary easing are out. If an interaction does not fit any category, that is a sign the interaction needs discussing, not the duration. - State feedback takes no
duration-*. The150msdefault is the correct value, and writingduration-150only adds noise. - Direction matters. Entering
ease-out, leavingease-in. Expand and collapse is the exception,ease-outboth ways, because the content is pushing layout rather than floating above it. - Respect
prefers-reduced-motion. The package already usesmotion-reduce:on continuous animation (indeterminate progress, for example). Do the same in your own animation. - Charts and data transitions have no defined standard. That gap is still open:
shadcn-vue/ Unovis brings the library's own default animation, and EDS has not decided whether to override it. Use the default and flag the gap.
4. Absolute generation rules — Vue (EDSnext / EDSlegacy)
For React (EDSreact), the equivalent rules are in section 10 (10.2 to 10.7). The underlying principle is the same, never a raw value and always a token, only the syntax changes.
- Never use HEX, RGB, HSL or any raw color value.
- Never use fixed numeric values for spacing, radius, font-size, font-weight, line-height or breakpoint.
- Never use inline
style="..."with a hard-coded value. - Exception: SVG, logo, illustration and artwork do not follow the token rule. Inside an SVG, a drawn icon, an illustration or any artwork, a hardcoded color value is the correct choice, not a violation. Two reasons: there is no token for every
fillin an illustration, and, more importantly, artwork that depicts the interface must not follow the theme. The canonical case is the Header theme selector (HeaderModeSelector.vue), which draws a thumbnail of the screen withbg-[#f8fafc],bg-[#0f172a],bg-[#e2e8f0]andbg-[#1e293b]: the light theme swatch has to stay light while the user is in dark, so a semantic token would break the very thing the swatch exists to show. Same logic for the logo SVG, which section 12.1 tells you to copy literally with thefillhardcoded. What the exception does not cover: an actual UI surface (background, border, text, the hover state of an interactive element) is still a token, even when the value is written inside an arbitrary-value class. - Always use
var(--ui-*)for semantic color. - For spacing, radius, typography, shadow and breakpoint, use the Tailwind v4 utility classes (
gap-2,p-4,rounded-lg,text-sm,font-medium,shadow-xs,md:,lg:). If you need the raw CSS variable, the name is Tailwind's own:--spacing,--radius-*,--text-*,--font-weight-*,--shadow-*,--breakpoint-*. Values in sections 6, 7 and 8. --eds-*does not exist. No file in the package defines that namespace: verified by grep acrosspackages/ui/app/assets/*.css, the bundle (packages/ui/.nuxt/ui.css) anddist/. The only occurrences in the repo are this file andskills/eds-next-tokens/references/tokens-full.md. Generatingvar(--eds-spacing-4)produces CSS that resolves to nothing. If you find--eds-*in code, it is a bug, not a convention.- Always express spacing, radius and typography in rem, never in px. Reminder:
1rem = 16px. The Tailwind classes are already rem, so using the classes is how you comply. - Never confuse the institutional brand palette (marketing, brand book, presentations) with the product color tokens (
--ui-*). They serve different purposes and hold different values (see section 12). - Always cover dark mode when a background or text color is used, except on EDSlegacy, where dark mode does not exist and must not be suggested.
- Always guarantee visible focus (
:focus-visiblewith an outline based on--ui-primary). - Always guarantee AA contrast (
--ui-texton--ui-bg,--ui-text-invertedon--ui-primary). - Always guarantee responsiveness with the standard Tailwind breakpoints (
smto2xl, see section 6.1). Never define a custom breakpoint. - New components are always TypeScript.
- Brazilian Portuguese for all product copy, unless another language is explicitly requested.
- Always use Lucide as the product's standard icon library. No other icon library may be used. Source of truth for the icon name: https://lucide.dev/icons/, the official gallery. The name on the site is kebab-case (
file-input,messages-square) and it is exactly what follows the prefix in code:i-lucide-file-input(Vue, throughUIcon),lucide:file-input(Vue, in item config) or<FileInput />fromlucide-react(React, the same name in PascalCase). If the icon does not exist at https://lucide.dev/icons/, it does not exist: do not invent an alias and do not use a name from another library. - Always use the shadcn / shadcn-vue
chartcomponent for any chart (Recharts in React, Unovis/vue in Vue). Never install another charting library. - Always check whether the component already exists in the package (
@mercadoeletronico/eds-next-reactor Nuxt UI v3 / EDSnext) before creating a new one. Never reimplement from scratch a component that already exists in the library. - Never render the ME logo as typeset text ("me.", "ME" and so on). Always use the real SVG (see section 12). The package does not export the logo as a component yet, so the SVG code has to be copied literally.
5. Color Tokens — Vue (semantic color)
5.1 Where the tokens come from (confirmed in the code, not in the MCP)
Careful: the eletrods MCP serves a hand-written markdown file (docs/server/mcp/references/tokens.md), not introspection of the real CSS. It can drift from the code without breaking anything, and it has drifted before. Do not blindly trust the MCP or the docs site for an exact hex, check the repo when you need precision.
The --ui-* tokens come from three different origins, and none of them is a tokens.css file:
a) The neutral scale and text / background / border, hardcoded in @nuxt/ui v4.10.0 (dist/runtime/index.css, also compiled into packages/ui/dist/style.css):
| Token | Light | Dark |
|---|---|---|
--ui-text-dimmed | --ui-color-neutral-400 | --ui-color-neutral-500 |
--ui-text-muted | --ui-color-neutral-500 | --ui-color-neutral-400 |
--ui-text-toned | --ui-color-neutral-600 | --ui-color-neutral-300 |
--ui-text | --ui-color-neutral-700 | --ui-color-neutral-200 |
--ui-text-highlighted | --ui-color-neutral-900 | #fff |
--ui-text-inverted | #fff | --ui-color-neutral-900 |
--ui-bg | #fff | --ui-color-neutral-900 |
--ui-bg-muted | --ui-color-neutral-50 | --ui-color-neutral-800 |
--ui-bg-elevated | --ui-color-neutral-100 | --ui-color-neutral-800 |
--ui-bg-accented | --ui-color-neutral-200 | --ui-color-neutral-700 |
--ui-bg-inverted | --ui-color-neutral-900 | #fff |
--ui-border | --ui-color-neutral-200 | --ui-color-neutral-800 |
--ui-border-muted | --ui-color-neutral-200 | --ui-color-neutral-700 |
--ui-border-accented | --ui-color-neutral-300 | --ui-color-neutral-700 |
--ui-border-inverted | --ui-color-neutral-900 | #fff |
Non-semantic, same file: --ui-radius: 0.25rem, --ui-header-height: 4rem, --ui-container: 80rem.
b) The --ui-color-{alias}-{50..950} families and the 6 --ui-{alias} aliases live in no file at all, they are generated at runtime by a Vue plugin (@nuxt/ui/dist/runtime/plugins/colors.js) from appConfig.ui.colors. The pattern is --ui-color-{key}-{shade}: var(--color-{tailwindAlias}-{shade}, <Tailwind default hex>), and --ui-{key} points at stop 500 (light) or 400 (dark) of that scale.
Aliases as resolved today. Source of truth: packages/ui/app/app.config.ts, which contains exactly ui: { colors: { primary: 'blue' } }. Every other alias comes from the Nuxt UI v4 default. Do not use packages/ui/.nuxt/app.config.mjs to check: it is a generated artifact and can be stale (in the local copy from 2026-08-10 it still says primary: "green", the Nuxt UI default, because it was generated before the override).
Consequence for a consuming app: the package's app.config.ts applies inside the DS playground itself. An app installing @mercadoeletronico/eds-next has to declare primary: 'blue' in its own app.config.ts, otherwise Nuxt UI resolves --ui-primary to green. That is not optional.
| Alias | Tailwind scale |
|---|---|
primary | blue (customised, see below) |
secondary | blue |
success | green (Tailwind default) |
info | blue |
warning | yellow (Tailwind default) |
error | red (Tailwind default) |
neutral | slate (Tailwind default, with no --ui-neutral alias, only the --ui-color-neutral-* scale) |
Which yields, actually existing at runtime: --ui-color-{alias}-{50,100,...,950} (7 families × 11 stops) plus --ui-primary, --ui-secondary, --ui-success, --ui-info, --ui-warning and --ui-error.
Hover and active use no token of their own, they use an opacity modifier. There is no --ui-primary-strong, nor anything ending in -strong, and that is not a gap: it is the library's convention. Verified in the compiled UButton theme (packages/ui/.nuxt/ui/button.ts), where hover and active apply opacity over the semantic color itself, with the same value in both states:
| Variant | Hover and active |
|---|---|
solid | hover:bg-{color}/75 and active:bg-{color}/75 |
soft | hover:bg-{color}/15 and active:bg-{color}/15 |
ghost and link | hover:bg-{color}/10 and active:bg-{color}/10 |
| Neutral surface | hover:bg-accented/75, hover:bg-elevated, hover:bg-inverted/90 |
| Table row | hover:bg-elevated/50 |
| Fixed-color surface (the blue Header bar) | hover:bg-black/10. --color-black is a Tailwind token, so the class is not an arbitrary value. In the Header code this currently appears as hover:bg-[rgba(0,0,0,0.1)], the same result written in arbitrary form: prefer bg-black/10 in new code. Do not use --ui-bg-inverted here, because it inverts with the theme and the bar does not |
where {color} is one of the aliases from section 5.1. Non-interactive components (Badge, Alert, Card) have no hover at all in the theme, so do not invent one.
Do not use an adjacent stop of the scale for hover (-600 instead of -500). That was this file's earlier recommendation and it was wrong: it diverged from what the components do, and in dark mode it moved to the wrong side of the scale. An opacity modifier behaves the same in both themes, which is precisely why the library does it that way.
In React the intent is the same with slightly different numbers, verified in button.tsx: hover:bg-primary/80 on solid, hover:bg-destructive/20 and /30 on destructive, hover:bg-muted and hover:bg-muted/50 on a neutral surface. A minor divergence to settle some day: Vue uses /75 and React uses /80 for the same state. Until there is a decision, follow the number of the stack you are in.
c) The brand hexes: only the blue scale is customised, in packages/ui/app/assets/primary.css (--color-blue-50 to --color-blue-950). It is the only file in the package that defines a brand color; green, red, yellow and slate stay at the Tailwind 4 default.
| Stop | Hex | Use |
|---|---|---|
| blue-50 | #EDF3FF | |
| blue-100 | #D0E0FF | |
| blue-200 | #A9C6FF | |
| blue-300 | #7CA8FF | |
| blue-400 | #437FFF | --ui-primary in dark |
| blue-500 | #1052E0 | --ui-primary in light |
| blue-600 | #0C3DA8 | |
| blue-700 | #082970 | |
| blue-800 | #061D4E | |
| blue-900 | #041438 | |
| blue-950 | #020C22 |
A known divergence between the docs site and the package: the site (eletro.design) overrides green, red, yellow and blue-600 with its own values, different from the published package (docs/app/assets/css/main.css). The published package is the absolute source of truth for generating code, since it is what a consuming app actually installs and renders. The docs site is misaligned on those tokens and should not be used to confirm an exact hex until it is fixed.
5.2 Real values of the neutral scale and the state colors
Verified in node_modules/tailwindcss/theme.css (Tailwind v4.3.3, what the repo resolves today). They are oklch, not hex. Do not convert them to hex when generating code, use the token name; the list below exists for anyone who has to render outside Tailwind (a plain HTML prototype, an email, Figma).
--ui-color-neutral-* resolves to --color-slate-*:
| Stop | Real value |
|---|---|
| slate-50 | oklch(98.4% 0.003 247.858) |
| slate-100 | oklch(96.8% 0.007 247.896) |
| slate-200 | oklch(92.9% 0.013 255.508) |
| slate-300 | oklch(86.9% 0.022 252.894) |
| slate-400 | oklch(70.4% 0.04 256.788) |
| slate-500 | oklch(55.4% 0.046 257.417) |
| slate-600 | oklch(44.6% 0.043 257.281) |
| slate-700 | oklch(37.2% 0.044 257.287) |
| slate-800 | oklch(27.9% 0.041 260.031) |
| slate-900 | oklch(20.8% 0.042 265.755) |
| slate-950 | oklch(12.9% 0.042 264.695) |
State colors, at the two stops the aliases use (500 in light, 400 in dark):
| Alias | Scale | Stop 500 (light) | Stop 400 (dark) |
|---|---|---|---|
--ui-success | green | oklch(72.3% 0.219 149.579) | oklch(79.2% 0.209 151.711) |
--ui-warning | yellow | oklch(79.5% 0.184 86.047) | oklch(85.2% 0.199 91.936) |
--ui-error | red | oklch(63.7% 0.237 25.331) | oklch(70.4% 0.191 22.216) |
--ui-primary | blue (custom) | #1052E0 | #437FFF |
--ui-secondary, --ui-info | blue (custom) | #1052E0 | #437FFF |
Blue is the only one still expressed in hex, because it comes from the package's own primary.css (section 5.1c) and not from Tailwind. Confirmed in the file: besides the 11 stops, primary.css also declares --font-sans: "Roboto", sans-serif (this is where the product font enters, see section 8).
5.3 Dark mode in Vue: the real mechanism
It is not plain prefers-color-scheme, and it is not the next-themes from section 10.7. It is the package's own useTheme() composable (packages/ui/app/composables/useTheme/useTheme.ts), built on VueUse's useColorMode:
- Three modes:
'light' | 'dark' | 'auto', defaultauto. - Persisted in
localStorageunder the keynuxt-color-mode(with a read fallback tovueuse-color-scheme, so a preference from an older app is not lost). - Effect on the DOM, on
<html>: it adds or removes thedarkclass and writesdata-theme="light|dark". Inautomode thedata-themeattribute is removed and only thedarkclass follows the system. - Nuxt UI matches this through
@variant dark (&:where(.dark, .dark *))and@variant light (&:where(.light, .light *)), declared in@nuxt/ui/dist/runtime/index.css.
In other words: the dark mode selector is the same in both stacks (a .dark class on an ancestor), only the controller differs (useTheme in Vue, next-themes in React). When writing your own CSS, express the light and dark pair as :root (or .light) and .dark, never inside a media query.
The theme switching UI already exists in the Header avatar menu (HeaderAvatar.vue), with the lucide:moon icon in dark and lucide:sun in light. Do not build a new toggle.
6. Spacing — Vue (multiples of 0.25rem / 4px)
Default unit: rem, not px. Remember:
1rem = 16px(the browser's base font size). The px values below are visual reference only.
The real mechanism: Tailwind v4 defines a single base variable, --spacing: 0.25rem, and derives everything else by multiplication. Neither --spacing-4 nor --eds-spacing-4 exists. You consume it through the class (p-4, gap-2, size-6, mt-8), and the class expands to calc(var(--spacing) * n). In your own CSS, the supported form is Tailwind's --spacing(n) function.
| Class | Resolved value | px equivalent | When to use |
|---|---|---|---|
gap-1, p-1 | 0.25rem | 4px | gap between icon and label |
gap-2, p-2 | 0.5rem | 8px | compact padding |
gap-3, p-3 | 0.75rem | 12px | chip and tag padding, table cell padding-x |
gap-4, p-4 | 1rem | 16px | default button padding |
gap-6, p-6 | 1.5rem | 24px | card padding |
gap-8, p-8 | 2rem | 32px | gap between sections |
gap-12, p-12 | 3rem | 48px | container spacing |
Fractional steps (p-2.5, gap-1.5) are valid and do appear in the package code. Use them sparingly, and only when density demands it.
6.1 Breakpoints
The standard Tailwind scale (identical in both stacks), and never define a custom breakpoint. The real values are in rem, not px: --breakpoint-sm to --breakpoint-2xl in tailwindcss/theme.css.
| Prefix | Token | Real value | px equivalent | Typical use |
|---|---|---|---|---|
sm: | --breakpoint-sm | 40rem | 640px | light adjustment, still mobile |
md: | --breakpoint-md | 48rem | 768px | tablet, columns start splitting |
lg: | --breakpoint-lg | 64rem | 1024px | standard desktop, sidebar visible |
xl: | --breakpoint-xl | 80rem | 1280px | large desktop, more columns |
2xl: | --breakpoint-2xl | 96rem | 1536px | wide screens |
Container queries are also a package convention, not just viewport breakpoints. Subheader.vue and Forehead.vue use @container with @min-[700px]: and @2xl: to adapt to the space of the slot rather than the window. In a block that lives inside a layout with a sidebar, a container query is the correct choice.
7. Radius — Vue
Default unit: rem (except
rounded-full, which uses a fixed px value by convention, since it stands for "infinite").
The real names are Tailwind v4's (--radius-*), and the scale has 8 steps, not 4. The four values this file used to document were right, only the prefix was wrong.
| Class | Token | Value | px equivalent | Use |
|---|---|---|---|---|
rounded-xs | --radius-xs | 0.125rem | 2px | minimal detail, rare |
rounded-sm | --radius-sm | 0.25rem | 4px | default input |
rounded-md | --radius-md | 0.375rem | 6px | default button |
rounded-lg | --radius-lg | 0.5rem | 8px | card |
rounded-xl | --radius-xl | 0.75rem | 12px | modal |
rounded-2xl | --radius-2xl | 1rem | 16px | large overlay |
rounded-3xl | --radius-3xl | 1.5rem | 24px | rare in product |
rounded-4xl | --radius-4xl | 2rem | 32px | rare in product |
rounded-full | (fixed) | 9999px | 9999px | avatar, pill badge |
Nuxt UI keeps its own --ui-radius: 0.25rem separately, which is the base radius of the library components (section 5.1a). Do not confuse the two: --ui-radius is the components' internal default, --radius-* is the scale you apply in your own layout.
7.1 Elevation (Shadow)
The base color is always black with opacity, never a colored shadow. Prefer the low levels (2xs to sm) day to day; lg, xl and 2xl are for overlays (modal, popover, floating dropdown). A single rule for both Vue and React: never use an invented or arbitrary shadow value (neither raw CSS nor Tailwind's shadow-[0_2px_4px_...]), always one of these tokens or classes.
The official scale is Tailwind v4's (--shadow-*), consumed through the shadow-2xs to shadow-2xl classes. There is no --eds-shadow-*.
| Class | Token | Real CSS (Tailwind v4.3.3) | Use |
|---|---|---|---|
shadow-2xs | --shadow-2xs | 0 1px rgb(0 0 0 / 0.05) | Subtle bottom border, a divider with minimal depth |
shadow-xs | --shadow-xs | 0 1px 2px 0 rgb(0 0 0 / 0.05) | Input, card at rest, the Header bar |
shadow-sm | --shadow-sm | 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) | Elevated card, button on hover |
shadow-md | --shadow-md | 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) | Dropdown, context menu |
shadow-lg | --shadow-lg | 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) | Popover, floating tooltip |
shadow-xl | --shadow-xl | 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1) | Modal, dialog |
shadow-2xl | --shadow-2xl | 0 25px 50px -12px rgb(0 0 0 / 0.25) | Maximum emphasis overlay (rare, an onboarding spotlight for example) |
The package does not override --shadow-*, on purpose. Two practical consequences:
- When generating or reviewing code: use the Tailwind class. The code is correct by definition.
- The
md,lgandxleffects in the Figma library do not follow this scale yet. A Figma screen with a shadow at those three levels will look slightly different from the implementation, and in that case the implementation is the reference, not Figma.
7.2 Border width and icon size
Two gaps that surfaced while generating code outside Tailwind, now closed:
- Border width has no token and does not need one. Tailwind v4 exposes no
--border-width-*namespace (verified intheme.css). Use the classes:border(1px),border-2,border-t,border-b. The "never a fixed px" rule from section 4 applies to a value written by hand in CSS, not to these classes. For the border color, use the Nuxt UI semantic utilities:border-default(which maps to--ui-border) andborder-accented(which maps to--ui-border-accented), the two actually used in the package. - Icon size comes from the spacing scale. Use
size-*, which resolves tocalc(var(--spacing) * n). The convention observed in the package code:size-4(1rem) for an icon inside text or a small button,size-5(1.25rem) for a standard action,size-6(1.5rem) in the Header chrome (menu, search and cart on mobile).
8. Product typography
Important: the product uses Roboto, not Montserrat, in both stacks (Vue and React). Montserrat is exclusive to marketing and institutional material. That is an active, deliberate difference between the two systems, not an oversight.
The token names below use the Vue naming. In React the family arrives through
--font-sans/--font-headinginjected by the consuming app, see section 10.6.
Family. The real token is --font-sans, declared in packages/ui/app/assets/primary.css inside @theme static as "Roboto", sans-serif. That is the token Tailwind uses as --default-font-family, so font-sans and body already inherit Roboto with no work from you. There is no --eds-font-family. Just make sure the consuming app loads the Roboto webfont.
Sizes. The real names are --text-*, consumed through text-xs to text-3xl. Each size brings its paired line-height in --text-{size}--line-height, and in v4 that value is a unitless ratio (calc(1.25 / 0.875)), not a rem. Using the text-sm class already applies the size and line-height pair, which is what "always paired" means in practice.
| Class | --text-* | Paired line-height | Rendered |
|---|---|---|---|
text-xs | 0.75rem | calc(1 / 0.75) | 12px / 16px |
text-sm | 0.875rem | calc(1.25 / 0.875) | 14px / 20px |
text-base | 1rem | calc(1.5 / 1) | 16px / 24px |
text-lg | 1.125rem | calc(1.75 / 1.125) | 18px / 28px |
text-xl | 1.25rem | calc(1.75 / 1.25) | 20px / 28px |
text-2xl | 1.5rem | calc(2 / 1.5) | 24px / 32px |
text-3xl | 1.875rem | calc(2.25 / 1.875) | 30px / 36px |
Weights. The real names are --font-weight-*. Tailwind exposes nine steps (thin 100 to black 900), but in product use only four: font-normal (400) for body, font-medium (500) for labels, buttons and table headers, font-semibold (600) for page and section titles, font-bold (700) for a highlighted number (a KPI). Weights 100, 200, 300, 800 and 900 exist in Tailwind and have no use in product.
9. Semantic map per component — Vue
Read this before the table. This map serves two cases: reviewing whether a component uses the right color, and rendering outside Nuxt UI (a plain HTML prototype, an email, Figma). Inside Vue, a component that exists in the library is configured through props, not through a hand-written token. Writing
background: var(--ui-primary)on a<UButton>is a violation, not an implementation.
| Component | Background | Text | Border | Radius |
|---|---|---|---|---|
| Button primary | --ui-primary | --ui-text-inverted | — | rounded-md |
| Button secondary | --ui-bg | --ui-primary | --ui-primary | rounded-md |
| Button ghost | transparent | --ui-text | — | rounded-md |
| Card | --ui-bg-elevated | --ui-text | --ui-border | rounded-lg |
| Input | --ui-bg | --ui-text | --ui-border | rounded-sm |
| Input | --ui-bg | --ui-text | --ui-primary | rounded-sm |
| Badge (neutral example) | --ui-bg-accented | --ui-text-muted | — | rounded-full |
| Header (desktop) | --ui-primary | white (text-white) | brand.iconColor when provided | — (full width bar, shadow-xs, height --ui-header-height) |
| Header (mobile) | --ui-bg | --ui-text | — | — |
| Subheader | inherits --ui-bg (it is a UDashboardToolbar) | --ui-text | the toolbar border | — |
| Table (MeTable) | its own namespace, see below |
Badge and Alert follow the same rule: props, never a manual token. The Badge row above is only the neutral example. The real UBadge accepts 7 colors (primary, secondary, success, info, warning, error, neutral) × 4 variants (solid, outline, soft, subtle) × 5 sizes (xs to xl), verified in packages/ui/.nuxt/ui/badge.ts. A colored status is <UBadge color="success" variant="soft" />, not a hand-painted background. The same holds for UAlert (<UAlert color="success" variant="soft" /> or variant="subtle"), which resolves to bg-{color}/10 text-{color} on soft, with an extra ring on subtle.
9.1 MeTable has its own token namespace
packages/ui/app/assets/me-table.css declares the table tokens in a separate namespace, outside the --ui-* scale, with raw oklch values and heights in px. It is deliberate according to the file's own comment ("all visual tokens are owned here"), so when working on the table use these names and do not try to translate them into --ui-*:
| Token | Value | Role |
|---|---|---|
--color-table-border | oklch(0.92 0.01 240) | cell and header border |
--color-table-row-hover | oklch(0.97 0.005 240) | row hover |
--color-table-row-selected | oklch(0.95 0.02 230) | selected row |
--color-table-header-bg | oklch(0.98 0.005 240) | header background |
--color-table-muted | oklch(0.6 0.01 240) | secondary cell text |
--color-table-focus-ring | oklch(0.7 0.18 250) | focus ring |
--table-row-height-compact / default / comfortable | 32px / 40px / 56px | the three densities |
--table-cell-padding-x / -y | 0.75rem / 0.5rem | cell padding |
--table-resize-handle-width | 4px | column resize handle |
--table-highlight-* | primary and secondary come from --color-blue-*; success, warning and error are local oklch | row highlight bar |
--me-ctx-* | its own set | context menu (it is teleported to <body>, so it has to be global) |
Dark mode overrides come in :root.dark in the same file.
Target state. The namespace stays (the table does need its own tokens for density and for the teleported context menu), but every token is meant to derive from the --ui-* scale instead of carrying hand-written oklch, and the measurements move to rem. The table below is the target, not the current state of the file:
| Table token | Value today | Target |
|---|---|---|
--color-table-border | oklch(0.92 0.01 240) | var(--ui-border) |
--color-table-header-bg | oklch(0.98 0.005 240) | var(--ui-bg-muted) |
--color-table-row-hover | oklch(0.97 0.005 240) | --ui-bg-elevated at 50%, which is the hover:bg-elevated/50 the Nuxt UI table theme already uses |
--color-table-row-selected | oklch(0.95 0.02 230) | --ui-primary at 10%, aligned with the soft pattern from section 5.1 |
--color-table-muted | oklch(0.6 0.01 240) | var(--ui-text-muted) |
--color-table-focus-ring | oklch(0.7 0.18 250) | var(--ui-primary) |
--table-resize-handle-color | oklch(0.85 0.02 240) | var(--ui-border-accented) |
--table-resize-handle-color-active | oklch(0.65 0.18 250) | var(--ui-primary) |
--table-resize-handle-width | 4px | 0.25rem, that is var(--spacing) |
--table-row-height-compact | 32px | 2rem |
--table-row-height-default | 40px | 2.5rem |
--table-row-height-comfortable | 56px | 3.5rem |
--table-highlight-success | -warning | -error | hand-written oklch | var(--ui-success) | var(--ui-warning) | var(--ui-error) |
--table-highlight-primary | -secondary | -info | --color-blue-* | var(--ui-primary) and stops of the primary scale |
--table-highlight-neutral | --color-table-muted | var(--ui-text-muted) |
--me-ctx-bg | -border | -text | -text-muted | hand-written oklch | --ui-bg | --ui-border | --ui-text | --ui-text-muted |
--me-ctx-item-hover-bg | --color-table-row-hover | --ui-bg-elevated at 50% |
--me-ctx-danger-color | -warning-color | -success-color | hand-written oklch | --ui-error | --ui-warning | --ui-success |
--me-ctx-shadow | 0 12px 40px oklch(...) | var(--shadow-md), the dropdown and context menu level from section 7.1 |
--me-ctx-kbd-bg | -kbd-border | hand-written oklch | --ui-bg-muted | --ui-border |
Two notes for whoever executes this:
- px to rem is a refactor with no visual change (
32px=2remat the base font size), so it can be done safely. - The colors are not pixel-identical. The current oklch values were written by hand and land close to, not equal to, the semantic equivalents. The migration shifts the border, hover and header colors by a few percentage points and needs visual validation, it is not a mechanical swap. The gain: the table starts following the theme and any palette adjustment like the rest of the product, instead of needing parallel maintenance.
9.2 Header, Subheader and Forehead are three different things
A common generation mistake is treating "subheader" as a bar with breadcrumb, title and actions. That is not what the package delivers.
- Header (
app/blocks/Header/): the global bar,h-16,z-[10000],shadow-xs, white text. The full layout contract is in 9.3, and it is what fixes the alignment mistakes that show up in generated screens. Composed ofHeaderBrand(logo),HeaderNavigation(navigation items),HeaderAvatar(profile menu, theme selector and language submenu) andHeaderActions(used when no user is logged in). On mobile it switches toHeaderAvatarMobileand reveals the menu, search and cart icons. Main props:user,brand,navigationItems,profileItems,siteMapItems,actionItems,hideOptions,cartBadge,disableModeSelector. It renders a 64pxUSkeletonwhile there is neitheruser.namenoractionItems. - Subheader (
app/blocks/Subheader/): a screen toolbar, not an editorial header. It wrapsUDashboardToolbarand has three regions: theleftslot (default: the sidebar toggle button withlucide:menuand the create group withlucide:plusplus alucide:chevron-downdropdown), the default slot in the centre (default:MeButtonBar) and therightslot (default: search withlucide:searchandlucide:list-filter, the chart toggle withlucide:chart-column, and the grid mode selector). Props:variant(primary|secondary|tertiary),toggleSidebar,createActions,buttonBar,search,toggleChart,gridMode,back,confirm. The slot defaults only appear onvariant="primary"and above 700px of container width. - Forehead (
app/components/Forehead/): a record or document header, not a screen header. Structure: an optionalMeForeheadBadgeon the left (banner variants by icon, image, user or text), aleadingslot and atrailingslot (which aligns right from@2xlup), plus adrawerslot that opens below whenopenDraweris set. Props:bannerVariant,textBanner,imageBanner,userBanner,iconBanner,dropdownItems,openDrawer. The screen title and metadata go in through the slots, the component has no title prop.
9.3 Header anatomy: the layout contract
Taken straight from the components (app/blocks/Header/). This is the section to follow when the request is "build a screen with the ME header": generation tools get the color right and the structure wrong, always at the same points.
The bar has exactly two groups, and that is what defines the alignment. Header.vue uses <nav class="flex items-center justify-between h-16"> with two children and nothing else:
┌───────────────────────────────────────────────────────────────────────────────────────┐
│ [logo] [Dashboard] │ [Transações][Catálogos][Usuários][Mais] │
│ │ [Mensagens][Genius] [avatar] │
└───────────────────────────────────────────────────────────────────────────────────────┘
└─ group 1: the logo only └─ group 2: navigation + avatar, all of
(on mobile, the hamburger menu first) it pushed right by ml-auto
| Element | Rule verified in the code |
|---|---|
| Bar | h-16 (which is --ui-header-height), z-[10000], shadow-xs, text-white. Background var(--ui-primary) on desktop and var(--ui-bg) on mobile |
| Group 1 (left) | The brand only. HeaderBrand is an <a> with flex items-center justify-center px-4 shrink-0 and the image at h-8 w-auto max-w-[120px] (h-5 on mobile, using brand.logoMobile when present) |
| Group 2 (right) | HeaderNavigation carries ml-auto on its root (ml-auto flex justify-center h-full items-stretch), so navigation and avatar sit flush right. Never place the menu items right after the logo |
| Navigation item | Stacked, not inline: grid place-items-center gap-1 px-[12px] py-2, with the icon (size-5) on top and the label below (text-xs font-medium leading-4 whitespace-nowrap). Icon and label both take the color from brand.iconColor |
| Active state | A 4px bar at the bottom edge of the item, through ::after with h-1 w-full rounded-t-full bottom-0 left-0, in the iconColor. It is not a filled pill, not a darker background, not underlined text |
| Item hover | bg-black/10 with transition-colors duration-200 |
| Item badge | Anchored to the icon, absolute -top-1 -right-3, color="error", size="sm", min-w-5, pill. It disappears when the value is empty or "0" |
| Separator | A vertical rule of 1px by 40px, opacity-35, mx-[12px], self-center, in the iconColor. The default set has two: after Dashboard and before Mensagens |
| Avatar (desktop) | The avatar only. The trigger is pl-6 pr-4 h-full flex items-center with a UAvatar size="xl" showing user.acronym, root bg-black/30 and a transparent border that becomes border-white border-2 on group hover. A UChip at bottom-right when user.badge is present |
| User name | It does not appear in the bar. Name, role and email live inside the dropdown, in the #avatar-header slot, through UUser (name plus description = role or email). The dropdown opens with align: 'end' and width w-62 |
| Mobile | The menu (lucide:menu) sits at the far left, before the logo. On the right, search (lucide:search) and cart (lucide:shopping-cart, with a counting UBadge), and HeaderAvatarMobile in place of the desktop avatar |
| No user | If there is neither user.name nor actionItems, the component renders a 64px tall USkeleton, not an empty bar |
Recurring generation mistakes, with the correct form beside them:
| Mistake | Correct |
|---|---|
| Menu items aligned left, right after the logo | Aligned right, in the same group as the avatar (ml-auto) |
| Icon beside the label in a menu item | Icon on top, label below, both centred |
| Active item as a filled pill or a darker background | A 4px bar at the bottom of the item |
| Avatar accompanied by the user name and a chevron in the bar | The avatar only, with the initials. Name and role only inside the dropdown |
| Mensagens and Genius as loose icons without a label | They are ordinary navigation items, with icon and label |
| No separators | Two vertical rules at 35% opacity delimiting the three navigation blocks |
10. React stack (@mercadoeletronico/eds-next-react)
Based on the
me-eds-next-reactrepo (branchdevelop), package@mercadoeletronico/eds-next-react1.1.1. Check theAGENTS.mdat the root of the monorepo before assuming anything here is still true.
10.1 Repository architecture
A pnpm + Turborepo monorepo:
me-eds-next-react/
├── apps/
│ └── web/ # Next.js playground (private, not published)
│ ├── app/ # App Router
│ └── components/ # App-level components (ThemeProvider, for example)
├── build/ # Dockerfile, Jenkinsfile, CI scripts
└── packages/
├── ui/ # @mercadoeletronico/eds-next-react (published package)
│ └── src/
│ ├── components/ # One file per component, kebab-case (button.tsx, alert-dialog.tsx)
│ ├── hooks/ # Shared hooks (use-mobile.ts)
│ ├── lib/ # utils.ts (the cn helper)
│ └── styles/ # globals.css (tokens + base styles)
├── storybook/ # @mercadoeletronico/storybook, private and EMPTY (zero stories, see section 2)
├── eslint-config/
└── typescript-config/
Two things the repo's AGENTS.md does not list yet but that do exist in the tree: packages/storybook (scaffolding without a story, see section 2) and build/ (Dockerfile, Jenkinsfile and CI scripts). The repo's AGENTS.md is the reference for conventions, not for a folder inventory.
Built on shadcn/ui (components.json with "style": "radix-nova", baseColor: "neutral", cssVariables: true) plus Radix UI (radix-ui, @base-ui/react) as primitives. Icons: Lucide through the lucide-react package, the product's mandatory standard icon library, no other (Heroicons, Font Awesome, Material Icons). Check the icon name at https://lucide.dev/icons/ and import it in PascalCase: the site's file-input is import { FileInput } from "lucide-react". The repo's components.json already declares iconLibrary: "lucide".
10.2 How to style (different from Vue)
In Vue (EDSnext) the convention is a Tailwind utility class in the template, with var(--ui-*) in <style scoped> when a semantic color has to enter your own CSS (--eds-* does not exist, see section 4). In React the difference is not that, it is the absence of <style scoped>. The repo's real convention is:
- Tailwind v4 utilities directly in
className, no CSS Modules, no styled-components, no<style>. - Class composition always through the
cn()helper:import { cn } from "@mercadoeletronico/eds-next-react/lib/utils" // cn() = clsx(...) + twMerge(...) — never concatenate a class string by hand - Component variants always through
class-variance-authority(cva), neverif/elseon className scattered through the JSX.
A real template of a component with variants (button.tsx, abridged):
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@mercadoeletronico/eds-next-react/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted",
secondary: "bg-secondary text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20",
link: "text-primary underline-offset-4 hover:underline",
},
size: { default: "h-8 px-2.5", sm: "h-7 px-2.5 text-[0.8rem]", lg: "h-9 px-2.5" },
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
Mandatory patterns in every new component:
- A named export (never
export default). data-slot="component-name"always present (it is the hook used by other components and by composition CSS,has-data-[slot=card-footer]for example).data-variantanddata-sizemirroring the props, when the component has variants.- Typing through
React.ComponentProps<"tag">(the element's native props)&VariantProps<typeof xVariants>(the cva variant props). - Support for polymorphic composition (
asChildplusSlot.Rootfrom theradix-uipackage) when the component can wrap another element (Button, Badge and so on). - File in kebab-case (
button-group.tsx), function and export in PascalCase (ButtonGroup).
10.3 Imports — a non-negotiable rule
Never import with a relative path that crosses the package (../../lib/utils). Always through the package subpath:
import { cn } from "@mercadoeletronico/eds-next-react/lib/utils"
import { Button } from "@mercadoeletronico/eds-next-react/components/button"
This is checked in the repo's own PR checklist (AGENTS.md): a relative import breaks the exports map in package.json when the package is consumed from outside.
10.4 Tokens — shadcn naming (different from Vue) and the equivalence table
The tokens live in packages/ui/src/styles/globals.css, defined as CSS custom properties on :root and .dark, and exposed to Tailwind through @theme inline. The naming is shadcn/ui's, not the Nuxt UI v3 naming used in Vue:
| Design intent | Vue token (EDSnext) | React token (eds-next-react) | Tailwind class |
|---|---|---|---|
| Brand primary color | --ui-primary | --primary | bg-primary / text-primary |
| Text on a primary background | --ui-text-inverted | --primary-foreground | text-primary-foreground |
| Secondary action | --ui-secondary | --secondary / --secondary-foreground | bg-secondary |
| Error / destructive | --ui-error | --destructive | text-destructive / bg-destructive/10 |
| Default text | --ui-text | --foreground | text-foreground |
| Secondary text, metadata | --ui-text-muted | --muted-foreground | text-muted-foreground |
| Page background | --ui-bg | --background | bg-background |
| Elevated card background | --ui-bg-elevated | --card / --card-foreground | bg-card |
| Default border | --ui-border | --border | border-border (or plain border, already mapped) |
| Border and focus ring | --ui-border-accented | --ring | focus-visible:ring-ring/50 |
Real values of the brand blue palette (identical in intent to Vue): the --color-blue-50 to --color-blue-950 scale. Light primary = blue-500 (#1052E0), dark primary = blue-400 (#437FFF), the same values the --ui-primary token already documents in Vue, only exposed as a palette scale (--color-blue-*) instead of a single semantic token. The full table with all 11 stops is in 5.1.
Known gap, do not invent: globals.css has no --success, --warning or --info (Vue has --ui-success, --ui-warning and --ui-info). Of the 32 tokens on :root, only --destructive exists for state. This is the only token gap left, and closing it depends on deciding the values with the team, not on reading code. If a React component needs a success or warning state, do not create a new value on your own: use the closest semantically available one (chart-*, or primary for a neutral info) and flag the gap to the Eletro team instead of pinning a new hex inside the component.
10.5 Radius and spacing (its own scale, do not copy from Vue)
Radius is derived from a single base variable (--radius: 0.625rem) through calc(), with a different numeric scale from Vue's. Do not assume 1:1 parity:
| React token | Formula | Approximate px | Tailwind class |
|---|---|---|---|
--radius-sm | calc(var(--radius) * 0.6) | ~6px | rounded-sm |
--radius-md | calc(var(--radius) * 0.8) | ~8px | rounded-md |
--radius-lg | var(--radius) | 10px | rounded-lg (button and input default) |
--radius-xl | calc(var(--radius) * 1.4) | ~14px | rounded-xl (card) |
--radius-2xl to --radius-4xl | larger multiples | 18–26px | modals, pill badge (rounded-4xl) |
Spacing has no custom scale of its own in either stack. Vue and React both use the standard Tailwind scale directly in the classes (gap-1.5, px-2.5, h-8) and, when a component needs a reusable variable, they create a local custom property per component ([--card-spacing:--spacing(4)] inside Card itself, for example). The --eds-spacing-N this file used to document exists nowhere, see section 4.
Elevation uses Tailwind's shadow-2xs to shadow-2xl utility classes, the same scale and rule as section 7.1.
10.6 Typography in React
The family (--font-sans, also mapped to --font-heading) is injected by the consuming app (through next/font in the host's Next.js), not hardcoded in the package. The business rule still holds: product means Roboto. The package itself does not force the font, it only consumes the variable, so make sure the integrating app loads Roboto and exposes it as --font-sans.
10.7 Dark mode in React
The mechanism is next-themes, not plain prefers-color-scheme.
// apps/web/components/theme-provider.tsx
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
Tailwind recognises dark mode through @custom-variant dark (&:is(.dark *)) in globals.css. In other words: dark mode depends on the .dark class on an ancestor (controlled by next-themes), not on an automatic media query by default. When generating a new component, make sure every semantic color used (bg-*, text-*, border-*) already has a counterpart on :root and .dark. If you are using an existing token from globals.css that comes for free; if you are using an arbitrary color, you have broken rule 10.4.
10.8 Adding a new component
Before anything else: confirm the component does not already exist in packages/ui/src/components/ or in the public docs (eletro.design). Only then follow the flow the repo expects (documented in AGENTS.md):
pnpm dlx shadcn@latest add <component-name> -c apps/web
That writes straight into packages/ui/src/components/. There is no need to wire anything up manually: the ./components/* wildcard export in the package's package.json covers any new file automatically.
Checklist before considering it done (from AGENTS.md itself):
- Component created through the shadcn CLI or manually in
packages/ui/src/components/. - Uses
@mercadoeletronico/eds-next-react/*imports, never relative ones. pnpm typecheckpasses.pnpm lintpasses.- A usage example added in
apps/webfor visual validation. pnpm devconfirms it renders correctly.
10.9 Commit, branch and PR conventions (React)
- Commit:
Jira MW-XXXXX #time Xh - description(not conventional commits). - Branch:
feature/MW-XXXXXandfix/MW-XXXXXfromdevelop;hotfix/MW-XXXXXandhotfeature/MW-XXXXXfrommaster. - MR: squash and merge. The PR title includes the Jira code.
10.10 Consuming app setup (Next.js host)
When another team consumes the package:
// next.config.ts
const nextConfig = { transpilePackages: ["@mercadoeletronico/eds-next-react"] }
// postcss.config.mjs
export { default } from "@mercadoeletronico/eds-next-react/postcss.config";
// app/layout.tsx
import "@mercadoeletronico/eds-next-react/globals.css"
/* The host's global CSS, Tailwind v4 */
@import "tailwindcss";
@source "../node_modules/@mercadoeletronico/eds-next-react/src";
10.11 Charts
The shadcn pattern: pnpm dlx shadcn@latest add chart installs ChartContainer, ChartConfig, ChartTooltip and ChartLegend into packages/ui/src/components/, backed by Recharts. Colors come from the --chart-1 to --chart-N tokens (the same var(--color-...) logic as section 10.4). Never install Chart.js, ApexCharts, Nivo, Highcharts or any other charting library, and never build a chart from scratch without going through the shadcn CLI. The Vue equivalent is shadcn-vue chart, backed by Unovis/vue.
11. ME component gallery
Each link below opens the component's public documentation on
eletro.design, with real screenshots on the page itself. This file embeds no images, only the reference list.
- ButtonBar — https://eletro.design/components/buttonbar
- CriterionInput — https://eletro.design/components/criterioninput
- DualList — https://eletro.design/components/duallist
- Forehead — https://eletro.design/components/forehead
- Input — https://eletro.design/components/input
- InputDate — https://eletro.design/components/inputdate
- InputDateMultiple — https://eletro.design/components/inputdatemultiple
- InputMultiple — https://eletro.design/components/inputmultiple
- LoginForm — https://eletro.design/components/loginform
- NavBar — https://eletro.design/components/navbar
- SelectMultiple — https://eletro.design/components/selectmultiple
- Spinner — https://eletro.design/components/spinner
- Tooltip — https://eletro.design/components/tooltip
- MeHeader (bonus, the global header) — see the screenshot on the block page
12. Institutional brand: the ME logo
Source: ME Brandbook (August 2024). The product SVGs used day to day are inline in section 12.1, copy the code directly. Known gap: the PNG files (the
Logos-ME/Marketing/andLogos-ME/Produto/folders) have no public URL documented here yet, today they only exist locally. If you need a specific PNG, flag the gap instead of assuming a path.
12.1 Logo for product
A simplified version of the me. logo, with no institutional context, for use inside the product itself (favicon, splash screen, empty state, loading, a light watermark). Three solid color variants, transparent background. Pick by the surface you are applying it to:
| Variant | When to use | File |
|---|---|---|
| White | On dark backgrounds or on --ui-primary | Logos-ME/Produto/logo-me-produto-branco.png |
| Blue | On light or neutral backgrounds (--ui-bg, --ui-bg-elevated) | Logos-ME/Produto/logo-me-produto-azul.png |
| Black | Monochrome or greyscale contexts | Logos-ME/Produto/logo-me-produto-preto.png |
The real SVG (Blue Primary variant). Copy this code literally, never type "me." as text:
<svg width="224" height="96" viewBox="0 0 224 96" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M122.013 43.674V84.8039H101.178V46.8764C101.178 35.2589 95.9725 29.919 87.5575 29.919C78.2125 29.919 71.665 35.9264 71.665 48.7514V84.8039H50.8376V46.8764C50.8376 35.2589 45.895 29.919 37.2175 29.919C28 29.919 21.4601 35.9264 21.4601 48.7514V84.8039H0.632568V12.9614H20.53V21.2414C25.87 15.0989 33.8801 11.8965 43.0976 11.8965C53.1101 11.8965 61.6601 15.7664 66.7301 23.6489C72.4751 16.3064 81.8201 11.8965 92.6351 11.8965C109.728 11.8965 122.013 21.7815 122.013 43.6815" fill="#1052E0"/><path d="M155.777 28.3815C163.675 28.3815 170.417 32.7915 172.892 39.459H138.61C141.257 32.7915 148.022 28.3815 155.777 28.3815ZM183.955 23.949C176.927 15.7665 166.72 11.079 155.95 11.079C145.795 11.079 136.39 15.0015 129.475 22.119C122.747 29.049 119.042 38.199 119.042 47.904C119.042 57.609 122.747 66.819 129.475 73.7565C136.382 80.8815 145.787 84.8115 155.95 84.8115C172.292 84.8115 186.4 74.649 191.05 59.529L191.35 58.554H171.55L171.332 58.944C168.317 64.3815 162.392 67.764 155.86 67.764C146.657 67.764 139.037 61.8765 137.41 53.649H192.235L192.332 53.0115C193.922 42.609 190.87 32.0265 183.947 23.964" fill="#1052E0"/><path d="M209.523 84.804C202.12 84.804 195.678 79.314 195.678 70.959C195.678 62.604 202.12 57.114 209.523 57.114C216.925 57.114 223.368 62.604 223.368 70.959C223.368 79.314 216.925 84.804 209.523 84.804Z" fill="#1052E0"/></svg>
For the White and Black variants, use the same SVG above, swapping fill="#1052E0" for fill="#FFFFFF" (white) or fill="#314158" (black) in all three <path> elements.
- Never apply a gradient, shadow or effect over the product logo.
- Keep the minimum clear space (see 12.3) even in a product context.
- This is the only logo allowed inside the product UI. Never use the marketing versions (with a colored background or frame) embedded in product screens.
12.2 Use in the platform header
The real component in Figma: EDSNext · Foundations — Logo. The logo color in the header follows the bar background, never the marketing versions:
| Header context | Logo | File (SVG) |
|---|---|---|
Blue background (--ui-primary, #1052E0), the dashboard top bar and main navigation | White | Logos-ME/Produto/logo-me-produto-branco.svg |
| White or light background, public screens, login, compact and mobile header | Blue Primary | Logos-ME/Produto/logo-me-produto-azul-primary.svg |
| White or light background, monochrome or neutral context | Black | Logos-ME/Produto/logo-me-produto-preto.svg |
Official SVGs available in Logos-ME/Produto/: logo-me-produto-branco.svg, logo-me-produto-azul-primary.svg (#1052E0, recommended for product), logo-me-produto-azul-default.svg (#0C3DA8, an alternative variant, not to be confused with the institutional blue #0A0A93) and logo-me-produto-preto.svg (#314158).
12.3 Institutional logo (marketing)
Use it in institutional pieces, presentations, marketing material and the brand book.
Versions, in order of preference:
- Main (negative): white on a blue background. The preferred version. File:
Logos-ME/Marketing/logo-me-marketing-negativa.png - Alternative (positive): blue on a white background. Only when the negative is not possible. File:
Logos-ME/Marketing/logo-me-marketing-positiva.png - Black: exclusively for a technical application limit (monochrome printing, for example). File:
Logos-ME/Marketing/logo-me-marketing-preto.png - White on black: the same technical exception, for dark backgrounds that are not blue. File:
Logos-ME/Marketing/logo-me-marketing-branco-sobre-preto.png
Specifications:
- Clear space: a module of 2x the height by the width of the logo's dot.
- Minimum reduction: 100mm / 30px (simple signature) up to 350mm / 100px (signature with a product,
me.marketplacefor example). Never reduce below that. - Application over colored backgrounds: only the main institutional colors (see the palette below).
- Application over an image or photographic background: forbidden.
- Misuse: do not rotate it, do not change the proportion between letters, do not change the typography, do not change the color, do not apply patterns or effects, do not use it in the middle of a sentence, do not intrude on the clear space.
ME product signature: the ME logo plus a descriptive term (me.source-to-pay, me.marketplace, me.ai-genius, for example).
Institutional brand colors (different from the EDS product tokens, see sections 5 and 10):
| Color | Pantone | Hex | RGB | CMYK | Use |
|---|---|---|---|---|---|
| Main blue | Blue 072 C | #0A0A93 | 10, 10, 147 | 100, 99, 5, 7 | The lead color, 80% of the layout |
| Support pink | 807 C | #FF38C6 | 255, 56, 198 | 10, 83, 0, 0 | Support and contrast, 20% of the layout |
| Support blue | 2995 C | #08BFE0 | 8, 191, 224 | 69, 0, 9, 0 | Support and contrast, background for the positive logo |
| Support green | 374 C | #BDE44E | 189, 228, 78 | 30, 0, 84, 0 | Support and contrast, background for the positive logo |
Careful: the institutional blue (
#0A0A93) is not the product blue (--ui-primary,#1052E0). Never use one in place of the other.
13. Components with known specific rules
- FilterBar: the cancel button (X) is icon only, with no label, tooltip "remover", visible from the start of the flow. The remove button (X) only appears after the first criterion is selected. No unit tests.
- DatePicker / InputDate: when handling an unavailable state, follow the state naming from Figma, and never decide a prop name in the specification (that is the developer's decision at implementation time).
- EDSlegacy: no screen, component or variant may reference dark mode anywhere (not in CSS, not in a prop, not in documentation copy).
- Header / MeHeader: generation tools frequently get the icon wrong on this component. The per-slot map is consolidated in section 13.1. Public documentation: https://eletro.design/blocks/header. Corrected on 2026-07-30: Transações
LogIntoFileInput, MensagensMessageSquaretoMessagesSquare, GeniusSparklestoWandSparkles. - A known Header bug, do not replicate it:
Header.vueuses'var(--text-inverted)'as thecomputedIconColorfallback whenbrand.iconColoris not provided. That variable does not exist (the Nuxt UI token is--ui-text-inverted), so the fallback resolves to nothing. When consuming the Header, passbrand.iconColorexplicitly. When fixing the component, the correct name is--ui-text-inverted.
13.1 Header icon map per slot
Verified in docs/content/5.blocks/header.md, blocks/Header/Header.vue and blocks/Header/HeaderAvatar.vue. All of them are Lucide. The navigation and profile items are configurable by the consuming app; what follows is the documented default set, which is what you should use when there is no instruction to the contrary.
Component chrome (fixed, not configurable):
| Slot | Icon |
|---|---|
| Mobile menu | lucide:menu |
| Mobile search | lucide:search |
| Mobile cart | lucide:shopping-cart |
| Theme mode in the avatar menu | lucide:moon in dark, lucide:sun in light |
| Active item in an avatar submenu | lucide:check |
Navigation items (documented default). The labels are the real product labels and stay in Portuguese:
| Item | Icon |
|---|---|
| Dashboard | lucide:layout-dashboard |
| Transações | lucide:file-input |
| Catálogos | lucide:shopping-bag |
| Usuários | lucide:users |
| Mais (opens the site map) | lucide:ellipsis |
| Mensagens | lucide:messages-square |
| Genius | lucide:wand-sparkles |
Profile menu items (documented default):
| Item | Icon |
|---|---|
| Trocar Conta | lucide:arrow-left-right |
| Idioma (Português - BR) | lucide:globe |
| Substituição | lucide:refresh-cw |
| Ver Perfil | lucide:user |
| Configurações Gerais | lucide:settings |
| Novidades e Atualizações | lucide:gift |
| Encerrar Sessão | lucide:log-out |
That is 19 verified slots, not the 23 this file used to estimate. If a slot shows up outside this list, it comes from the consuming app's configuration and not from the DS. Note: the profile item icons come in the docs with color: "#6b7280" hardcoded as a hex, which contradicts the rule in section 4. When consuming them, prefer leaving color out and inheriting text-muted.
14. Product tone of voice (inherited from the ME brand)
The tone of voice is the same one defined in the institutional Brandbook, applied to the reality of product microcopy: buttons, toasts, validation, empty states, tooltips.
Expert · Simple · Helpful
| Pillar | What it means in product |
|---|---|
| Expert | Use the correct term from the procurement domain (RFQ, cotação, fornecedor) without turning it into impenetrable jargon. If you need to simplify, simplify the explanation, not the term. |
| Simple | A short sentence, a clear action verb, no performative "please" and no elaborate grammar. |
| Helpful | Every error or blocking message points at the next step. It never leaves the user stuck with no way out. |
UX writing guardrails (inspired by the Nielsen Norman Group)
Product copy is written in Brazilian Portuguese, so the examples below stay in Portuguese on purpose.
- Clarity before personality. Product microcopy is not the place for brand creativity. The priority is that the user understands what happened and what to do in under 2 seconds.
- Errors never blame the user. Replace "Você preencheu errado" with "O CNPJ precisa ter 14 dígitos". Focus on the corrective action, not on the failure.
- One problem, one sentence. Avoid compound messages with several causes. If there is more than one validation, list them.
- Buttons describe the action, not the system. "Enviar cotação", not a generic "Confirmar". The user should not have to read the context to know what the button does.
- An empty state is an opportunity, not a desert. Always explain why it is empty and what the next possible action is (for example "Nenhuma cotação ainda. Crie a primeira cotação para começar.").
- Terminological consistency over stylistic variation. If the system calls it "fornecedor" on one screen, do not call it "supplier" or "parceiro" on another.
- Confirming a destructive action is always specific. Never a bare "Tem certeza?". Always "Excluir a cotação #4521? Essa ação não pode ser desfeita."
- Tooltips exist to reduce cognitive load, not to explain the obvious. If the label is already clear, do not add a redundant tooltip.
- A loading state has copy, not just a spinner, when the wait can exceed roughly 2s (for example "Buscando fornecedores compatíveis...").
- Clear Portuguese over a literal translation of English terms, when a natural PT-BR term is already established in the corporate procurement domain.
15. Final checklist before calling it done
The full rules are in sections 4 (Vue) and 10.2 to 10.7 (React). This is only the last pass, so do not repeat the rules here, just confirm each one was followed.
- No raw values (HEX / RGB / HSL, fixed px), only a token or a utility class (section 4 / 10.4).
- No
var(--eds-*)in the generated code, that namespace does not exist (section 4). - Library components configured through props, not through a hand-written token (section 9).
- Dark mode covered, except on EDSlegacy: the
:rootand.darkpair, never a media query (sections 4, 5.3 and 10.7). -
:focus-visible, AA contrast, keyboard navigation (section 4). - Copy in PT-BR, in the Expert / Simple / Helpful tone (section 14).
- Component verified as not already existing in the library before creating a new one (section 4).
- React only:
data-slot/data-variant/data-size, imports through the package subpath,cvafor variants, a named export (sections 10.2 and 10.3).
Recurring mistakes to avoid (not covered elsewhere)
- Never mention, suggest or link Storybook, EDS does not use it.
- Never paint large background areas with the primary color outside a marketing or hero context, that is not a product rule.
- Never decide a prop name in specification documentation, that is the developer's call; the spec only references the state in Figma.
- Never write an error message that blames the user or that ends without saying what to do next.
16. Sources of truth and references
- This page, the canonical design contract:
eletro.design/ai/designmd, source atdocs/content/3.ai/designmd.md - Public component documentation:
eletro.design - Base library documentation (Vue):
ui.nuxt.com - Icons: https://lucide.dev/icons/, the official Lucide gallery, is the source of truth for whether an icon exists and what it is called. Names are kebab-case on the site, with an individual page at
lucide.dev/icons/{name}. Do not use a third-party aggregator (icones.js.org and the like) as a reference: they lag behind releases and list collections EDS does not use, which has already led to a non-existent icon name in generated code. - Full token list (Vue):
references/tokens-full.md(through theedsnext-tokensskill). Careful: out of date. It is the other source that still documents the non-existent--eds-*namespace (section 4). Prefer sections 5 to 8 of this file until it is fixed. - Full semantic map per component (Vue):
references/semantic-map.md - Nuxt UI v3 / Tailwind v4 configuration (Vue):
references/nuxt-ui-config.md - React repository:
me-eds-next-react(internal GitLab) · published package@mercadoeletronico/eds-next-react - Agent guide in the React repo:
AGENTS.mdat the root of the monorepo (component checklist, commit and branch conventions, PR checklist)
17. Do's and Don'ts
A short index of the rules that cost the most rework when missed. Each line names the section that holds the reasoning, so this list stays an index and never becomes a second source of truth.
Do
- Use the Tailwind utility class for spacing, radius, typography and shadow. (4, 6, 7, 8)
- Use
var(--ui-*)for semantic color when a value has to enter your own CSS. (4, 5.1) - Configure a library component through its props. A component that exists in the package is never styled by a hand-written token. (9)
- Use an opacity modifier for hover and active:
/75solid,/15soft,/10ghost. (5.1) - Hardcode color inside an SVG, a drawn icon or an illustration. That is correct there, not a violation. (4)
- Flag a gap when a value is missing, and say which section is missing it. (0, 10.4)
Don't
- Don't write
--eds-*. No file in the package defines that namespace, so it resolves to nothing and fails silently. (4) - Don't use an adjacent scale stop for hover (
-600instead of-500). It diverges from the components and moves the wrong way in dark mode. (5.1) - Don't paint a large block with the primary color. The Header bar is the only exception in the whole product. (3)
- Don't suggest, generate or validate dark mode for EDSlegacy. It does not exist there. (2)
- Don't document a component in Storybook, and don't link to it. The empty package in the React monorepo is idle scaffolding, not a convention. (2)
- Don't install another charting library, and don't build a chart from scratch. (4, 10.11)
- Don't render the ME logo as typeset text. Copy the real SVG. (4, 12.1)
- Don't copy a token name from one stack to the other without checking the equivalence table. (2, 10.4)
18. Iteration Guide
How to run the work, as opposed to how the result should look. Section 15 checks the output after the fact; this section is what keeps you out of trouble before that.
- One component at a time. Check whether it already exists in the package, and in the public docs, before writing anything.
- Start from the target. EDSnext, EDSlegacy or EDSreact: token naming, the dark mode mechanism and the way you style all differ. Section 2 has the table.
- When a value is missing, flag the gap. Never fill it with an invented value. A documented gap is information; an invented value is a defect that propagates into code.
- Prefer a prop over a class, and a class over your own CSS. Reach for
<style>only when the first two genuinely cannot express it. - When in doubt about emphasis, use weight and text color before size. The product is dense; scale is the most expensive tool in the box.
- Verify against the code, not against the MCP or the docs site. Both can drift from the package, and both have drifted. Section 16 lists which source wins.
- A variant is not a new component.
-active,-disabledand-focusedare states of something that already exists.