diff --git a/front-admin/CLAUDE.md b/front-admin/CLAUDE.md
index 8579377..d32ba98 100644
--- a/front-admin/CLAUDE.md
+++ b/front-admin/CLAUDE.md
@@ -6,6 +6,8 @@ Próximos módulos a construir, nesta ordem (ver `../docs/produto.md`): edição
Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https://claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N#page-dcb81aee8d83).
+Kit de UI do tema Sertão (componentes `Ph*`, tokens, acessibilidade): [`docs/UI-KIT.md`](docs/UI-KIT.md). Use-o antes de criar markup próprio.
+
Full layer rules live in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — read that first, it's the actual convention document, not a summary. `.claude/rules/` covers what's specific to this being a template (which module is the reference, why `HttpClient` is shaped the way it is, the two different reasons something is "shared").
`app/modules/login/` is the reference module — a complete, working example of every layer, with tests for each. Imitate its shape when creating a new module.
diff --git a/front-admin/app/application/locales/pt-br/index.ts b/front-admin/app/application/locales/pt-br/index.ts
index db21bc8..f09b385 100644
--- a/front-admin/app/application/locales/pt-br/index.ts
+++ b/front-admin/app/application/locales/pt-br/index.ts
@@ -1,7 +1,9 @@
import login from '../../../modules/login/locales/pt-br'
+import ui from './ui'
// Every module's locale is registered here — see docs/ARCHITECTURE.md,
// "Registro obrigatório". Add one import + one key per new module.
export default {
login,
+ ui,
}
diff --git a/front-admin/app/application/locales/pt-br/ui.ts b/front-admin/app/application/locales/pt-br/ui.ts
new file mode 100644
index 0000000..bf194ff
--- /dev/null
+++ b/front-admin/app/application/locales/pt-br/ui.ts
@@ -0,0 +1,20 @@
+// Textos do kit de UI compartilhado (app/application/shared/components/ui).
+export default {
+ loading: 'Carregando…',
+ required: 'obrigatório',
+ close: 'Fechar',
+ dismiss: 'Dispensar aviso',
+ pagination: 'Paginação',
+ previousPage: 'Página anterior',
+ nextPage: 'Próxima página',
+ page: (n: number) => `Página ${n}`,
+ selectPlaceholder: 'Selecione…',
+ notifications: 'Avisos',
+ statusLabels: {
+ ok: 'Tudo certo',
+ warn: 'Atenção',
+ danger: 'Problema',
+ info: 'Informação',
+ neutral: 'Neutro',
+ },
+}
diff --git a/front-admin/app/application/shared/components/ui/Badge.test.ts b/front-admin/app/application/shared/components/ui/Badge.test.ts
new file mode 100644
index 0000000..fbd20ed
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Badge.test.ts
@@ -0,0 +1,21 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Badge from './Badge.vue'
+
+describe('PhBadge', () => {
+ it.each(['ok', 'warn', 'danger', 'info', 'neutral'] as const)('renderiza o estado %s com texto', async (status) => {
+ const wrapper = await mountSuspended(Badge, { props: { status }, slots: { default: 'Pago' } })
+ expect(wrapper.attributes('data-status')).toBe(status)
+ expect(wrapper.text()).toBe('Pago')
+ })
+
+ it('mostra o ponto por padrão e o esconde do leitor de tela', async () => {
+ const wrapper = await mountSuspended(Badge, { slots: { default: 'Pago' } })
+ expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
+ })
+
+ it('pode ocultar o ponto', async () => {
+ const wrapper = await mountSuspended(Badge, { props: { dot: false }, slots: { default: 'Pago' } })
+ expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Badge.vue b/front-admin/app/application/shared/components/ui/Badge.vue
new file mode 100644
index 0000000..5c87bf0
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Badge.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Button.test.ts b/front-admin/app/application/shared/components/ui/Button.test.ts
new file mode 100644
index 0000000..bd6bf7e
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Button.test.ts
@@ -0,0 +1,46 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Button from './Button.vue'
+
+describe('PhButton', () => {
+ it('renderiza como botão do tipo "button" com a variante primária', async () => {
+ const wrapper = await mountSuspended(Button, { slots: { default: 'Salvar' } })
+ const el = wrapper.find('button')
+ expect(el.text()).toBe('Salvar')
+ expect(el.attributes('type')).toBe('button')
+ expect(el.classes()).toContain('btn-primary')
+ })
+
+ it.each([
+ ['secondary', 'btn-neutral'],
+ ['ghost', 'btn-ghost'],
+ ['danger', 'btn-error'],
+ ] as const)('aplica a variante %s', async (variant, cls) => {
+ const wrapper = await mountSuspended(Button, { props: { variant } })
+ expect(wrapper.find('button').classes()).toContain(cls)
+ })
+
+ it('desabilitado: não dispara clique e fica com o atributo disabled', async () => {
+ const wrapper = await mountSuspended(Button, { props: { disabled: true } })
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined()
+ })
+
+ it('carregando: desabilita, marca aria-busy e avisa leitores de tela', async () => {
+ const wrapper = await mountSuspended(Button, { props: { loading: true } })
+ const el = wrapper.find('button')
+ expect(el.attributes('disabled')).toBeDefined()
+ expect(el.attributes('aria-busy')).toBe('true')
+ expect(el.text()).toContain('Carregando')
+ })
+
+ it('com "to" renderiza um link e não um botão', async () => {
+ const wrapper = await mountSuspended(Button, { props: { to: '/login' }, slots: { default: 'Entrar' } })
+ expect(wrapper.find('a').attributes('href')).toBe('/login')
+ expect(wrapper.find('button').exists()).toBe(false)
+ })
+
+ it('tem altura mínima de 44 px garantida pelo tema', async () => {
+ const wrapper = await mountSuspended(Button)
+ expect(wrapper.find('button').classes()).toContain('btn')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Button.vue b/front-admin/app/application/shared/components/ui/Button.vue
new file mode 100644
index 0000000..3a1f1c4
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Button.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {{ locale.loading }}
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Card.vue b/front-admin/app/application/shared/components/ui/Card.vue
new file mode 100644
index 0000000..2752dd9
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Card.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Checkbox.test.ts b/front-admin/app/application/shared/components/ui/Checkbox.test.ts
new file mode 100644
index 0000000..d6a0575
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Checkbox.test.ts
@@ -0,0 +1,23 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Checkbox from './Checkbox.vue'
+
+describe('PhCheckbox', () => {
+ it('rótulo clicável ligado à caixa', async () => {
+ const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar no site' } })
+ expect(wrapper.find('label').attributes('for')).toBe(wrapper.find('input').attributes('id'))
+ })
+
+ it('emite true ao marcar', async () => {
+ const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar', modelValue: false } })
+ await wrapper.find('input').setValue(true)
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([true])
+ })
+
+ it('desabilitado, com dica descrevendo o campo', async () => {
+ const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar', hint: 'Pode mudar depois.', disabled: true } })
+ const input = wrapper.find('input')
+ expect(input.attributes('disabled')).toBeDefined()
+ expect(wrapper.find(`#${input.attributes('aria-describedby')}`).text()).toBe('Pode mudar depois.')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Checkbox.vue b/front-admin/app/application/shared/components/ui/Checkbox.vue
new file mode 100644
index 0000000..679c564
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Checkbox.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
{{ hint }}
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Chip.test.ts b/front-admin/app/application/shared/components/ui/Chip.test.ts
new file mode 100644
index 0000000..cd1b700
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Chip.test.ts
@@ -0,0 +1,16 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Chip from './Chip.vue'
+
+describe('PhChip', () => {
+ it('expõe o estado selecionado por aria-pressed', async () => {
+ const wrapper = await mountSuspended(Chip, { props: { modelValue: true }, slots: { default: 'Todos' } })
+ expect(wrapper.find('button').attributes('aria-pressed')).toBe('true')
+ })
+
+ it('alterna ao clicar', async () => {
+ const wrapper = await mountSuspended(Chip, { props: { modelValue: false }, slots: { default: 'Pagos' } })
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([true])
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Chip.vue b/front-admin/app/application/shared/components/ui/Chip.vue
new file mode 100644
index 0000000..f290a08
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Chip.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Drawer.test.ts b/front-admin/app/application/shared/components/ui/Drawer.test.ts
new file mode 100644
index 0000000..b46bc99
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Drawer.test.ts
@@ -0,0 +1,21 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Drawer from './Drawer.vue'
+
+describe('PhDrawer', () => {
+ it('abre e fecha com v-model e fica ancorado à direita', async () => {
+ const wrapper = await mountSuspended(Drawer, { props: { title: 'Editar sessão', modelValue: false } })
+ const dialog = wrapper.find('dialog')
+ expect(dialog.classes()).toContain('modal-end')
+ await wrapper.setProps({ modelValue: true })
+ expect((dialog.element as HTMLDialogElement).open).toBe(true)
+ })
+
+ it('tem título como nome acessível e botão de fechar', async () => {
+ const wrapper = await mountSuspended(Drawer, { props: { title: 'Editar sessão', modelValue: true } })
+ const labelledBy = wrapper.find('dialog').attributes('aria-labelledby')
+ expect(wrapper.find(`#${labelledBy}`).text()).toBe('Editar sessão')
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Drawer.vue b/front-admin/app/application/shared/components/ui/Drawer.vue
new file mode 100644
index 0000000..ad4920a
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Drawer.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/EmptyState.vue b/front-admin/app/application/shared/components/ui/EmptyState.vue
new file mode 100644
index 0000000..de9c377
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/EmptyState.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Input.test.ts b/front-admin/app/application/shared/components/ui/Input.test.ts
new file mode 100644
index 0000000..a177a92
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Input.test.ts
@@ -0,0 +1,52 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Input from './Input.vue'
+
+describe('PhInput', () => {
+ it('associa o rótulo ao campo pelo id', async () => {
+ const wrapper = await mountSuspended(Input, { props: { label: 'Nome da edição' } })
+ const input = wrapper.find('input')
+ expect(wrapper.find('label').attributes('for')).toBe(input.attributes('id'))
+ })
+
+ it('atualiza o v-model ao digitar', async () => {
+ const wrapper = await mountSuspended(Input, {
+ props: { label: 'Nome', modelValue: '', 'onUpdate:modelValue': (v: string | number) => wrapper.setProps({ modelValue: v }) },
+ })
+ await wrapper.find('input').setValue('PHPeste 2027')
+ expect(wrapper.props('modelValue')).toBe('PHPeste 2027')
+ })
+
+ it('mostra a dica ligada ao campo por aria-describedby', async () => {
+ const wrapper = await mountSuspended(Input, { props: { label: 'Nome', hint: 'Aparece no site.' } })
+ const describedBy = wrapper.find('input').attributes('aria-describedby')
+ expect(describedBy).toBeTruthy()
+ expect(wrapper.find(`#${describedBy}`).text()).toBe('Aparece no site.')
+ })
+
+ it('erro: marca aria-invalid, anuncia como alerta e esconde a dica', async () => {
+ const wrapper = await mountSuspended(Input, {
+ props: { label: 'E-mail', hint: 'dica', error: 'E-mail inválido' },
+ })
+ const input = wrapper.find('input')
+ expect(input.attributes('aria-invalid')).toBe('true')
+ expect(input.classes()).toContain('input-error')
+ expect(wrapper.find('[role="alert"]').text()).toBe('E-mail inválido')
+ expect(wrapper.text()).not.toContain('dica')
+ })
+
+ it('desabilitado: o campo fica com o atributo disabled', async () => {
+ const wrapper = await mountSuspended(Input, { props: { label: 'Nome', disabled: true } })
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
+ })
+
+ it.each(['date', 'time', 'email'] as const)('aceita o tipo %s', async (type) => {
+ const wrapper = await mountSuspended(Input, { props: { label: 'Campo', type } })
+ expect(wrapper.find('input').attributes('type')).toBe(type)
+ })
+
+ it('obrigatório: indica no rótulo, sem depender só de cor', async () => {
+ const wrapper = await mountSuspended(Input, { props: { label: 'Data', required: true } })
+ expect(wrapper.find('label').text()).toContain('obrigatório')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Input.vue b/front-admin/app/application/shared/components/ui/Input.vue
new file mode 100644
index 0000000..dea37b3
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Input.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
{{ error }}
+
{{ hint }}
+
+
diff --git a/front-admin/app/application/shared/components/ui/Kpi.vue b/front-admin/app/application/shared/components/ui/Kpi.vue
new file mode 100644
index 0000000..fb858e9
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Kpi.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+ - {{ label }}
+ - {{ value }}
+ - {{ hint }}
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Modal.test.ts b/front-admin/app/application/shared/components/ui/Modal.test.ts
new file mode 100644
index 0000000..6a4adb2
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Modal.test.ts
@@ -0,0 +1,35 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Modal from './Modal.vue'
+
+describe('PhModal', () => {
+ it('fica fechado por padrão e abre com v-model', async () => {
+ const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: false }, slots: { default: 'texto' } })
+ const dialog = wrapper.find('dialog').element as HTMLDialogElement
+ expect(dialog.open).toBe(false)
+ await wrapper.setProps({ modelValue: true })
+ expect(dialog.open).toBe(true)
+ })
+
+ it('nomeia o diálogo pelo título', async () => {
+ const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: true } })
+ const labelledBy = wrapper.find('dialog').attributes('aria-labelledby')
+ expect(wrapper.find(`#${labelledBy}`).text()).toBe('Reembolsar')
+ })
+
+ it('o botão de fechar avisa quem usa o componente', async () => {
+ const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: true } })
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
+ })
+
+ it('fecha ao clicar fora (no fundo) e mostra o rodapé quando existe', async () => {
+ const wrapper = await mountSuspended(Modal, {
+ props: { title: 'Reembolsar', modelValue: true },
+ slots: { footer: '' },
+ })
+ expect(wrapper.text()).toContain('Confirmar')
+ await wrapper.find('dialog').trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Modal.vue b/front-admin/app/application/shared/components/ui/Modal.vue
new file mode 100644
index 0000000..82c998c
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Modal.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Pagination.test.ts b/front-admin/app/application/shared/components/ui/Pagination.test.ts
new file mode 100644
index 0000000..2c5c672
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Pagination.test.ts
@@ -0,0 +1,26 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Pagination from './Pagination.vue'
+
+describe('PhPagination', () => {
+ it('marca a página atual com aria-current', async () => {
+ const wrapper = await mountSuspended(Pagination, { props: { page: 2, pageCount: 9 } })
+ expect(wrapper.find('[aria-current="page"]').text()).toBe('2')
+ expect(wrapper.find('nav').attributes('aria-label')).toBe('Paginação')
+ })
+
+ it('emite a página escolhida e respeita os limites', async () => {
+ const wrapper = await mountSuspended(Pagination, { props: { page: 1, pageCount: 3 } })
+ const buttons = wrapper.findAll('button')
+ await buttons[buttons.length - 1]!.trigger('click')
+ expect(wrapper.emitted('update:page')?.at(-1)).toEqual([2])
+ })
+
+ it('desabilita "anterior" na primeira página e "próxima" na última', async () => {
+ const first = await mountSuspended(Pagination, { props: { page: 1, pageCount: 3 } })
+ expect(first.findAll('button')[0]!.attributes('disabled')).toBeDefined()
+ const last = await mountSuspended(Pagination, { props: { page: 3, pageCount: 3 } })
+ const buttons = last.findAll('button')
+ expect(buttons[buttons.length - 1]!.attributes('disabled')).toBeDefined()
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Pagination.vue b/front-admin/app/application/shared/components/ui/Pagination.vue
new file mode 100644
index 0000000..9f6967f
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Pagination.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Select.test.ts b/front-admin/app/application/shared/components/ui/Select.test.ts
new file mode 100644
index 0000000..c6d26f2
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Select.test.ts
@@ -0,0 +1,30 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Select from './Select.vue'
+
+const options = [
+ { value: 'cfp', label: 'CFP aberto' },
+ { value: 'vendas', label: 'Vendas abertas' },
+]
+
+describe('PhSelect', () => {
+ it('lista as opções e o texto de seleção', async () => {
+ const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options, placeholder: 'Selecione…' } })
+ const texts = wrapper.findAll('option').map((o) => o.text())
+ expect(texts).toEqual(['Selecione…', 'CFP aberto', 'Vendas abertas'])
+ expect(wrapper.find('label').attributes('for')).toBe(wrapper.find('select').attributes('id'))
+ })
+
+ it('emite a opção escolhida', async () => {
+ const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options } })
+ await wrapper.find('select').setValue('vendas')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['vendas'])
+ })
+
+ it('erro e desabilitado', async () => {
+ const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options, error: 'Escolha uma etapa', disabled: true } })
+ expect(wrapper.find('select').attributes('aria-invalid')).toBe('true')
+ expect(wrapper.find('select').attributes('disabled')).toBeDefined()
+ expect(wrapper.find('[role="alert"]').text()).toBe('Escolha uma etapa')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Select.vue b/front-admin/app/application/shared/components/ui/Select.vue
new file mode 100644
index 0000000..76333aa
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Select.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
{{ error }}
+
{{ hint }}
+
+
diff --git a/front-admin/app/application/shared/components/ui/Skeleton.vue b/front-admin/app/application/shared/components/ui/Skeleton.vue
new file mode 100644
index 0000000..4755061
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Skeleton.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
{{ locale.loading }}
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Stripe.vue b/front-admin/app/application/shared/components/ui/Stripe.vue
new file mode 100644
index 0000000..142abb9
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Stripe.vue
@@ -0,0 +1,7 @@
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Table.vue b/front-admin/app/application/shared/components/ui/Table.vue
new file mode 100644
index 0000000..0402c64
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Table.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Tabs.test.ts b/front-admin/app/application/shared/components/ui/Tabs.test.ts
new file mode 100644
index 0000000..30a3c5a
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Tabs.test.ts
@@ -0,0 +1,57 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Tabs from './Tabs.vue'
+
+const items = [
+ { id: 'geral', label: 'Geral' },
+ { id: 'trilhas', label: 'Trilhas' },
+ { id: 'criterios', label: 'Critérios' },
+]
+
+async function mountTabs(active = 'geral') {
+ const wrapper = await mountSuspended(Tabs, {
+ props: { items, label: 'Seções', modelValue: active, 'onUpdate:modelValue': (v: string) => wrapper.setProps({ modelValue: v }) },
+ slots: { default: '
conteúdo
' },
+ })
+ return wrapper
+}
+
+describe('PhTabs', () => {
+ it('usa os papéis tablist, tab e tabpanel e liga painel e aba', async () => {
+ const wrapper = await mountTabs()
+ expect(wrapper.find('[role="tablist"]').attributes('aria-label')).toBe('Seções')
+ const tabs = wrapper.findAll('[role="tab"]')
+ expect(tabs).toHaveLength(3)
+ expect(tabs[0]!.attributes('aria-selected')).toBe('true')
+ const panel = wrapper.find('[role="tabpanel"]')
+ expect(panel.attributes('aria-labelledby')).toBe(tabs[0]!.attributes('id'))
+ expect(tabs[0]!.attributes('aria-controls')).toBe(panel.attributes('id'))
+ })
+
+ it('só a aba ativa entra na ordem de Tab (roving tabindex)', async () => {
+ const wrapper = await mountTabs()
+ const tabindexes = wrapper.findAll('[role="tab"]').map((t) => t.attributes('tabindex'))
+ expect(tabindexes).toEqual(['0', '-1', '-1'])
+ })
+
+ it('setas, Home e End trocam de aba', async () => {
+ const wrapper = await mountTabs()
+ const list = wrapper.find('[role="tablist"]')
+ await list.trigger('keydown', { key: 'ArrowRight' })
+ expect(wrapper.props('modelValue')).toBe('trilhas')
+ await list.trigger('keydown', { key: 'End' })
+ expect(wrapper.props('modelValue')).toBe('criterios')
+ await list.trigger('keydown', { key: 'ArrowRight' })
+ expect(wrapper.props('modelValue')).toBe('geral')
+ await list.trigger('keydown', { key: 'ArrowLeft' })
+ expect(wrapper.props('modelValue')).toBe('criterios')
+ await list.trigger('keydown', { key: 'Home' })
+ expect(wrapper.props('modelValue')).toBe('geral')
+ })
+
+ it('clicar numa aba a ativa', async () => {
+ const wrapper = await mountTabs()
+ await wrapper.findAll('[role="tab"]')[1]!.trigger('click')
+ expect(wrapper.props('modelValue')).toBe('trilhas')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Tabs.vue b/front-admin/app/application/shared/components/ui/Tabs.vue
new file mode 100644
index 0000000..b7f1635
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Tabs.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/Textarea.test.ts b/front-admin/app/application/shared/components/ui/Textarea.test.ts
new file mode 100644
index 0000000..b7da064
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Textarea.test.ts
@@ -0,0 +1,23 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Textarea from './Textarea.vue'
+
+describe('PhTextarea', () => {
+ it('associa rótulo e campo e mostra a dica', async () => {
+ const wrapper = await mountSuspended(Textarea, { props: { label: 'Mini bio', hint: 'Até 400 caracteres.' } })
+ const area = wrapper.find('textarea')
+ expect(wrapper.find('label').attributes('for')).toBe(area.attributes('id'))
+ expect(wrapper.text()).toContain('Até 400 caracteres.')
+ })
+
+ it('erro: aria-invalid e mensagem como alerta', async () => {
+ const wrapper = await mountSuspended(Textarea, { props: { label: 'Bio', error: 'Muito longo' } })
+ expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true')
+ expect(wrapper.find('[role="alert"]').text()).toBe('Muito longo')
+ })
+
+ it('desabilitado', async () => {
+ const wrapper = await mountSuspended(Textarea, { props: { label: 'Bio', disabled: true } })
+ expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/Textarea.vue b/front-admin/app/application/shared/components/ui/Textarea.vue
new file mode 100644
index 0000000..063d831
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/Textarea.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
{{ error }}
+
{{ hint }}
+
+
diff --git a/front-admin/app/application/shared/components/ui/ToastHost.test.ts b/front-admin/app/application/shared/components/ui/ToastHost.test.ts
new file mode 100644
index 0000000..f850d15
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/ToastHost.test.ts
@@ -0,0 +1,59 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { useToast } from '@/application/shared/composables/useToast'
+import { useToastStore } from '@/stores/toastStore'
+import ToastHost from './ToastHost.vue'
+
+beforeEach(() => {
+ vi.useRealTimers()
+ // O store do Nuxt é compartilhado entre os testes: limpa os avisos anteriores.
+ useToastStore().items.splice(0)
+})
+
+describe('PhToastHost + toastStore', () => {
+ it('é uma região viva e mostra o aviso disparado', async () => {
+ const wrapper = await mountSuspended(ToastHost)
+ useToast().success('Identidade publicada.')
+ await nextTick()
+ expect(wrapper.find('[aria-live="polite"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('Identidade publicada.')
+ })
+
+ it('erro usa role="alert" e os demais "status"', async () => {
+ const wrapper = await mountSuspended(ToastHost)
+ const toast = useToast()
+ toast.error('Falha ao enviar.')
+ toast.info('Salvo.')
+ await nextTick()
+ const roles = wrapper.findAll('.alert').map((a) => a.attributes('role'))
+ expect(roles).toEqual(['alert', 'status'])
+ })
+
+ it('dispensa o aviso pelo botão', async () => {
+ const wrapper = await mountSuspended(ToastHost)
+ useToast().info('Salvo.')
+ await nextTick()
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.text()).not.toContain('Salvo.')
+ })
+
+ it('executa a ação ("Desfazer") e remove o aviso', async () => {
+ const run = vi.fn()
+ const wrapper = await mountSuspended(ToastHost)
+ useToast().success('Publicada.', { label: 'Desfazer', run })
+ await nextTick()
+ const undo = wrapper.findAll('button').find((b) => b.text() === 'Desfazer')!
+ await undo.trigger('click')
+ expect(run).toHaveBeenCalledOnce()
+ expect(useToastStore().items).toHaveLength(0)
+ })
+
+ it('some sozinho depois do tempo', async () => {
+ vi.useFakeTimers()
+ useToast().info('Salvo.')
+ expect(useToastStore().items).toHaveLength(1)
+ vi.advanceTimersByTime(5001)
+ expect(useToastStore().items).toHaveLength(0)
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/ToastHost.vue b/front-admin/app/application/shared/components/ui/ToastHost.vue
new file mode 100644
index 0000000..a1f48d2
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/ToastHost.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ toast.message }}
+
+
+
+
+
diff --git a/front-admin/app/application/shared/components/ui/misc.test.ts b/front-admin/app/application/shared/components/ui/misc.test.ts
new file mode 100644
index 0000000..3378a52
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/misc.test.ts
@@ -0,0 +1,74 @@
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import Card from './Card.vue'
+import EmptyState from './EmptyState.vue'
+import Kpi from './Kpi.vue'
+import Skeleton from './Skeleton.vue'
+import Stripe from './Stripe.vue'
+import Table from './Table.vue'
+
+describe('PhCard', () => {
+ it('mostra título, conteúdo e ações', async () => {
+ const wrapper = await mountSuspended(Card, {
+ props: { title: 'Regras' },
+ slots: { default: 'corpo
', actions: '' },
+ })
+ expect(wrapper.find('h2').text()).toBe('Regras')
+ expect(wrapper.text()).toContain('corpo')
+ expect(wrapper.text()).toContain('Editar')
+ })
+
+ it('sem título e sem ações não cria cabeçalho', async () => {
+ const wrapper = await mountSuspended(Card, { slots: { default: 'corpo
' } })
+ expect(wrapper.find('header').exists()).toBe(false)
+ })
+})
+
+describe('PhKpi', () => {
+ it('relaciona rótulo e valor como lista de definição', async () => {
+ const wrapper = await mountSuspended(Kpi, { props: { label: 'Ingressos', value: 128, hint: 'de 400' } })
+ expect(wrapper.find('dt').text()).toBe('Ingressos')
+ expect(wrapper.findAll('dd').map((d) => d.text())).toEqual(['128', 'de 400'])
+ })
+})
+
+describe('PhTable', () => {
+ it('tem legenda acessível e região rolável focável', async () => {
+ const wrapper = await mountSuspended(Table, {
+ props: { caption: 'Pedidos recentes' },
+ slots: { default: '| #1 |
' },
+ })
+ expect(wrapper.find('caption').text()).toBe('Pedidos recentes')
+ const region = wrapper.find('[role="region"]')
+ expect(region.attributes('aria-label')).toBe('Pedidos recentes')
+ expect(region.attributes('tabindex')).toBe('0')
+ })
+})
+
+describe('PhSkeleton', () => {
+ it('anuncia "Carregando" e marca ocupado, sem expor as barras', async () => {
+ const wrapper = await mountSuspended(Skeleton, { props: { lines: 4 } })
+ expect(wrapper.attributes('aria-busy')).toBe('true')
+ expect(wrapper.text()).toContain('Carregando')
+ expect(wrapper.findAll('.skeleton')).toHaveLength(4)
+ })
+})
+
+describe('PhEmptyState', () => {
+ it('mostra título, descrição e a próxima ação', async () => {
+ const wrapper = await mountSuspended(EmptyState, {
+ props: { title: 'Nenhuma proposta ainda', description: 'Quando chegar a primeira, aparece aqui.' },
+ slots: { default: '' },
+ })
+ expect(wrapper.find('h3').text()).toBe('Nenhuma proposta ainda')
+ expect(wrapper.text()).toContain('Quando chegar a primeira')
+ expect(wrapper.text()).toContain('Ver formulário')
+ })
+})
+
+describe('PhStripe', () => {
+ it('é decorativa e fica escondida de leitores de tela', async () => {
+ const wrapper = await mountSuspended(Stripe)
+ expect(wrapper.attributes('aria-hidden')).toBe('true')
+ })
+})
diff --git a/front-admin/app/application/shared/components/ui/tokens.test.ts b/front-admin/app/application/shared/components/ui/tokens.test.ts
new file mode 100644
index 0000000..802c8ff
--- /dev/null
+++ b/front-admin/app/application/shared/components/ui/tokens.test.ts
@@ -0,0 +1,50 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+// Lê os tokens reais de main.css: se alguém trocar uma cor e quebrar o
+// contraste mínimo (WCAG AA = 4,5:1 para texto), este teste falha.
+const css = readFileSync(resolve(process.cwd(), 'app/assets/css/main.css'), 'utf8')
+
+function token(name: string): string {
+ const match = css.match(new RegExp(`--ph-${name}:\\s*(#[0-9a-fA-F]{6})`))
+ if (!match?.[1]) throw new Error(`Token --ph-${name} não encontrado em main.css`)
+ return match[1]
+}
+
+function luminance(hex: string): number {
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) =>
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
+ ) as [number, number, number]
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+function ratio(a: string, b: string): number {
+ const [hi, lo] = [luminance(token(a)), luminance(token(b))].sort((x, y) => y - x) as [number, number]
+ return (hi + 0.05) / (lo + 0.05)
+}
+
+const textPairs: Array<[string, string]> = [
+ ['ink', 'bg'], ['ink', 'surface'], ['ink', 'surface-2'],
+ ['ink-2', 'bg'], ['ink-2', 'surface'], ['ink-2', 'surface-2'],
+ ['ink-3', 'bg'], ['ink-3', 'surface'], ['ink-3', 'surface-2'],
+ ['link', 'bg'], ['link', 'surface'],
+ ['on-brand', 'brand'], ['on-brand', 'terra'],
+ ['ink', 'accent'],
+ ['ok', 'ok-bg'], ['warn', 'warn-bg'], ['danger', 'danger-bg'], ['info', 'info-bg'],
+ ['danger', 'surface'], ['ok', 'surface'], ['warn', 'surface'], ['info', 'surface'],
+ ['on-brand', 'ok'], ['on-brand', 'warn'], ['on-brand', 'danger'], ['on-brand', 'info'],
+]
+
+describe('contraste dos tokens do tema Sertão', () => {
+ it.each(textPairs)('texto --ph-%s sobre --ph-%s tem pelo menos 4,5:1', (fg, bg) => {
+ expect(ratio(fg, bg)).toBeGreaterThanOrEqual(4.5)
+ })
+
+ it.each([['brand', 'bg'], ['brand', 'surface']])(
+ 'anel de foco --ph-%s sobre --ph-%s tem pelo menos 3:1',
+ (fg, bg) => {
+ expect(ratio(fg, bg)).toBeGreaterThanOrEqual(3)
+ },
+ )
+})
diff --git a/front-admin/app/application/shared/composables/useDialog.ts b/front-admin/app/application/shared/composables/useDialog.ts
new file mode 100644
index 0000000..1f1de26
--- /dev/null
+++ b/front-admin/app/application/shared/composables/useDialog.ts
@@ -0,0 +1,23 @@
+import { onMounted, watch, type Ref } from 'vue'
+
+/**
+ * Liga um