Compare commits
9 Commits
fix/seed-t
...
feat/setup
| Author | SHA1 | Date | |
|---|---|---|---|
| 76b7673a51 | |||
| 457845758c | |||
| 0aee15e848 | |||
| b372eaea4f | |||
| 7380998e8e | |||
| 4b440b7e5b | |||
| 5949fad9b1 | |||
| d054d04ab9 | |||
| 946939ba94 |
14
AGENTS.md
14
AGENTS.md
@@ -41,3 +41,17 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
|
|||||||
## Security & Configuration
|
## Security & Configuration
|
||||||
|
|
||||||
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Single-context: `CONTEXT.md` at root + `docs/adr/`. See `docs/agents/domain.md`.
|
||||||
|
|||||||
12
composer.lock
generated
12
composer.lock
generated
@@ -2802,16 +2802,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.3",
|
"version": "2.9.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2848,7 +2848,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.9-dev"
|
"dev-main": "2.10-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -2905,7 +2905,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-12T15:29:16+00:00"
|
"time": "2026-08-03T13:42:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: >
|
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
|
||||||
sh -c
|
|
||||||
"php artisan migrate --force --no-interaction
|
|
||||||
&& (php artisan db:seed --class=ContentSeeder --force --no-interaction 2>&1
|
|
||||||
&& php artisan media:generate-variants --force 2>&1) || true
|
|
||||||
&& echo 'migrate+seed done'"
|
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
|
||||||
|
|||||||
51
docs/agents/domain.md
Normal file
51
docs/agents/domain.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Domain Docs
|
||||||
|
|
||||||
|
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||||
|
|
||||||
|
## Before exploring, read these
|
||||||
|
|
||||||
|
- **`CONTEXT.md`** at the repo root, or
|
||||||
|
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||||
|
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||||
|
|
||||||
|
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
Single-context repo (most repos):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT-MAP.md
|
||||||
|
├── docs/adr/ ← system-wide decisions
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── docs/adr/ ← context-specific decisions
|
||||||
|
└── billing/
|
||||||
|
├── CONTEXT.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the glossary's vocabulary
|
||||||
|
|
||||||
|
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||||
|
|
||||||
|
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||||
26
docs/agents/issue-tracker.md
Normal file
26
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Issue tracker: Linear
|
||||||
|
|
||||||
|
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
|
||||||
|
|
||||||
|
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
|
||||||
|
- **Team**: Maneco-workspace
|
||||||
|
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
|
||||||
|
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
|
||||||
|
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
|
||||||
|
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
|
||||||
|
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
|
||||||
|
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
|
||||||
|
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
|
||||||
|
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run `get_issue` on the identifier and read the description, state, labels, and assignee.
|
||||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Triage Labels
|
||||||
|
|
||||||
|
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||||
|
|
||||||
|
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||||
|
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Will not be actioned |
|
||||||
|
|
||||||
|
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||||
|
|
||||||
|
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||||
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
2
openspec/changes/enhance-public-motion/.openspec.yaml
Normal file
2
openspec/changes/enhance-public-motion/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-06
|
||||||
51
openspec/changes/enhance-public-motion/design.md
Normal file
51
openspec/changes/enhance-public-motion/design.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
O site público usa Blade/Tailwind e já contém um runtime pequeno em `resources/js/motion.js`: abertura focal da home, `page-open`, reveals com `IntersectionObserver` e progresso do índice. A cobertura e a marcação são parciais, os reveals não têm grupos/direções explícitas e a matriz browser se concentra nas quatro páginas visuais originais. A mudança cruza templates, CSS, JavaScript e testes, mas não envolve estado de aplicação, dados ou dependências.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Tornar a coreografia Heritage Editorial consistente em toda página pública visual.
|
||||||
|
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
|
||||||
|
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
|
||||||
|
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
|
||||||
|
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Não criar transições de rota, parallax, animação contínua ou repetição ao voltar no scroll.
|
||||||
|
- Não mudar copy, dados de clientes, estrutura de layout, CMS, controllers, banco, APIs ou Filament.
|
||||||
|
- Não adicionar biblioteca de animação nem itens de SPEC.md §4.2.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Contrato declarativo em atributos `data-*`.** Templates usarão `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal` e `data-reveal-from="up|left|right"`. CSS define o estado visual e JS apenas ativa classes. Alternativa rejeitada: acoplar seletores a classes de layout, por tornar a coreografia frágil a ajustes visuais.
|
||||||
|
|
||||||
|
2. **Enhancement opt-in pelo elemento raiz.** O HTML inicial permanece no estado final; o runtime adiciona `html[data-motion="enhance"]` somente quando movimento é permitido e os recursos necessários existem. Sem JS, sem `IntersectionObserver`, ou em `reduce`, o atributo não é aplicado/removido e todo conteúdo fica final. Alternativa rejeitada: estado inicial oculto no HTML/CSS, pois pode prender conteúdo fora da tela.
|
||||||
|
|
||||||
|
3. **Transformação sem fade de texto.** Entradas duram cerca de 500 ms com `cubic-bezier(0.16, 1, 0.3, 1)`, deslocamento vertical de 12 px e lateral de 24 px no desktop/16 px no mobile. Grupos recebem stagger de 90 ms limitado a 270 ms. Mídia pode combinar transform com `clip-path`; texto não usa opacidade. Alternativa rejeitada: fade geral, que reduz contraste durante axe e legibilidade percebida.
|
||||||
|
|
||||||
|
4. **Observer de execução única.** Cada reveal intersectado recebe o estado final e é removido do observer. Grupos calculam índice limitado para o delay; depoimentos definem explicitamente `left`/`right` pela coluna, preservando alternância no mobile. Alternativa rejeitada: reanimar em toda rolagem, por criar fadiga e instabilidade.
|
||||||
|
|
||||||
|
5. **Feedback separado das entradas.** CTAs, links, navegação e controles recebem transições curtas (100–200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
|
||||||
|
|
||||||
|
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
|
||||||
|
|
||||||
|
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
|
||||||
|
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
|
||||||
|
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
|
||||||
|
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
|
||||||
|
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Publicar CSS, JS e templates no mesmo bundle/commit; não há migração de dados. Rollback consiste em reverter esses assets e atributos, sem compatibilidade de schema ou limpeza operacional.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Nenhuma; direção, alternância mobile, execução única e limites de escopo foram aprovados no plano.
|
||||||
31
openspec/changes/enhance-public-motion/proposal.md
Normal file
31
openspec/changes/enhance-public-motion/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
O site público já possui uma abertura focal na home, mas o restante da experiência ainda muda de estado de forma desigual entre páginas e seções. WEB-01, WEB-07 e os requisitos transversais de acessibilidade pedem uma coreografia editorial consistente, progressiva e dispensável, sem ocultar conteúdo nem comprometer interação, performance ou movimento reduzido.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Evoluir o contrato de motion do site público com abertura de página, sequência editorial inicial e reveals direcionais executados uma única vez.
|
||||||
|
- Aplicar a coreografia à home, serviços, portfólio, detalhe, sobre, contato, privacidade e páginas de erro 404/419/429/500/503.
|
||||||
|
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
|
||||||
|
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
|
||||||
|
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
|
||||||
|
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
|
||||||
|
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `public-site-motion`: Coreografia progressiva, direções, timings, execução única, microfeedback, performance e fallbacks do motion público.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
|
||||||
|
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
|
||||||
|
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
|
||||||
|
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
|
||||||
|
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Afeta somente templates Blade públicos, `resources/js/motion.js`, tokens/regras CSS e testes Pest/Pest Browser. Não altera APIs, banco, controllers, CMS, dependências, endpoints técnicos (`sitemap.xml`, `robots.txt`) ou Filament.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Editorial motion tokens are centralized
|
||||||
|
|
||||||
|
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
|
||||||
|
|
||||||
|
#### Scenario: Motion runtime uses shared values
|
||||||
|
- **WHEN** page openings, reveals, or interaction feedback are styled
|
||||||
|
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public pages use shared progressive motion choreography
|
||||||
|
|
||||||
|
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
|
||||||
|
|
||||||
|
#### Scenario: Visual public route exposes motion hooks
|
||||||
|
- **WHEN** a visitor loads any visual public route or branded error page
|
||||||
|
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
|
||||||
|
|
||||||
|
#### Scenario: Technical endpoints remain outside motion
|
||||||
|
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
|
||||||
|
- **THEN** the response MUST NOT depend on the public motion runtime
|
||||||
|
|
||||||
|
### Requirement: Editorial entrance timing and direction are consistent
|
||||||
|
|
||||||
|
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
|
||||||
|
|
||||||
|
#### Scenario: Reveal group computes capped stagger
|
||||||
|
- **WHEN** more than four reveal items are rendered in one group
|
||||||
|
- **THEN** the applied delay MUST NOT exceed 270 ms
|
||||||
|
|
||||||
|
#### Scenario: Text enters without opacity fade
|
||||||
|
- **WHEN** page opening or scroll reveal motion runs
|
||||||
|
- **THEN** text MUST remain visible throughout the transition
|
||||||
|
|
||||||
|
### Requirement: Reveals execute once without blocking interaction
|
||||||
|
|
||||||
|
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
|
||||||
|
|
||||||
|
#### Scenario: Revealed content leaves observer
|
||||||
|
- **WHEN** a reveal target intersects the configured viewport threshold
|
||||||
|
- **THEN** it MUST receive the final state and be unobserved
|
||||||
|
- **AND** scrolling away and back MUST NOT replay the reveal
|
||||||
|
|
||||||
|
#### Scenario: CTA remains interactive during entrance
|
||||||
|
- **WHEN** a visitor activates a CTA while its entrance is in progress
|
||||||
|
- **THEN** navigation MUST proceed without waiting for animation completion
|
||||||
|
|
||||||
|
### Requirement: Motion enhancement has immediate safe fallbacks
|
||||||
|
|
||||||
|
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
|
||||||
|
|
||||||
|
#### Scenario: JavaScript is unavailable
|
||||||
|
- **WHEN** a public page renders without executing JavaScript
|
||||||
|
- **THEN** all content MUST be visible and usable in its final state
|
||||||
|
|
||||||
|
#### Scenario: IntersectionObserver is unavailable
|
||||||
|
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
|
||||||
|
- **THEN** all reveal targets MUST remain in their final state
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion is preferred
|
||||||
|
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
|
||||||
|
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
|
||||||
|
|
||||||
|
### Requirement: Public interaction feedback is short and accessible
|
||||||
|
|
||||||
|
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
|
||||||
|
|
||||||
|
#### Scenario: Keyboard focus remains visible
|
||||||
|
- **WHEN** a visitor focuses an interactive element with the keyboard
|
||||||
|
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
|
||||||
|
|
||||||
|
#### Scenario: Validation message appears without motion
|
||||||
|
- **WHEN** server validation renders an error message
|
||||||
|
- **THEN** the message MUST appear in its final position without entrance animation
|
||||||
|
|
||||||
|
### Requirement: Home progress updates at most once per animation frame
|
||||||
|
|
||||||
|
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
|
||||||
|
|
||||||
|
#### Scenario: Multiple scroll events occur in one frame
|
||||||
|
- **WHEN** multiple scroll events fire before the next animation frame
|
||||||
|
- **THEN** the home progress calculation MUST execute only once for that frame
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Visual public and error pages expose the shared motion contract
|
||||||
|
|
||||||
|
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
|
||||||
|
|
||||||
|
#### Scenario: Institutional page uses shared opening
|
||||||
|
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
|
||||||
|
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
|
||||||
|
|
||||||
|
#### Scenario: Error page remains branded and progressively enhanced
|
||||||
|
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
|
||||||
|
- **THEN** the page MUST expose the shared opening hook
|
||||||
|
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Testimonials are managed with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished testimonial is excluded
|
||||||
|
|
||||||
|
- **WHEN** a testimonial has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published testimonial is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||||
|
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage testimonials
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Featured testimonials are filterable
|
||||||
|
|
||||||
|
- **WHEN** content is queried with featured filter
|
||||||
|
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||||
|
|
||||||
|
#### Scenario: Multi-paragraph quotes render as paragraphs
|
||||||
|
|
||||||
|
- **GIVEN** a published testimonial whose quote contains blank-line separated paragraphs
|
||||||
|
- **WHEN** the home testimonials section is rendered
|
||||||
|
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
|
||||||
|
|
||||||
|
#### Scenario: Blank quotes do not affect alternation
|
||||||
|
|
||||||
|
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
|
||||||
|
- **WHEN** the home testimonials section is rendered
|
||||||
|
- **THEN** the blank testimonial MUST be omitted
|
||||||
|
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
|
||||||
|
|
||||||
|
#### Scenario: Direction alternation remains on mobile
|
||||||
|
|
||||||
|
- **WHEN** testimonials are viewed at a mobile viewport
|
||||||
|
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public screens have desktop and mobile visual baselines
|
||||||
|
|
||||||
|
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
|
||||||
|
|
||||||
|
#### Scenario: Unintended visual change fails the suite
|
||||||
|
|
||||||
|
- **GIVEN** approved baselines exist
|
||||||
|
- **WHEN** a code change alters the rendering of a covered screen
|
||||||
|
- **THEN** the visual assertion MUST fail and report the diff
|
||||||
|
|
||||||
|
#### Scenario: Both viewports are covered
|
||||||
|
|
||||||
|
- **WHEN** the visual suite runs
|
||||||
|
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
|
||||||
|
|
||||||
|
#### Scenario: Heritage Editorial identity is captured
|
||||||
|
|
||||||
|
- **WHEN** approved baselines for the home are reviewed after this change
|
||||||
|
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
|
||||||
|
|
||||||
|
#### Scenario: Motion does not destabilize new baselines
|
||||||
|
|
||||||
|
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
|
||||||
|
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public routes have no critical or serious accessibility issues
|
||||||
|
|
||||||
|
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
|
||||||
|
|
||||||
|
#### Scenario: Critical issue blocks the suite
|
||||||
|
|
||||||
|
- **WHEN** the automated accessibility check reports a critical or serious issue on a covered route
|
||||||
|
- **THEN** the browser suite MUST fail and report the offending rule and selector
|
||||||
|
|
||||||
|
#### Scenario: Covered routes are checked
|
||||||
|
|
||||||
|
- **WHEN** the accessibility suite runs
|
||||||
|
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
|
||||||
|
|
||||||
|
### Requirement: Reduced motion preference is honored
|
||||||
|
|
||||||
|
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables transitions
|
||||||
|
|
||||||
|
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
|
||||||
|
- **WHEN** a public page is loaded
|
||||||
|
- **THEN** decorative transitions and animations MUST NOT run
|
||||||
|
- **AND** every motion target MUST be in its final visible and interactive state
|
||||||
|
|
||||||
|
### Requirement: Public pages emit no console errors
|
||||||
|
|
||||||
|
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
|
||||||
|
|
||||||
|
#### Scenario: Console stays clean on covered routes
|
||||||
|
|
||||||
|
- **WHEN** a covered public route is loaded in the browser suite
|
||||||
|
- **THEN** the console MUST contain no error-level messages
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Motion enhancement failure does not hide or disable content
|
||||||
|
|
||||||
|
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
|
||||||
|
|
||||||
|
#### Scenario: JavaScript disabled keeps public page usable
|
||||||
|
|
||||||
|
- **WHEN** a visitor loads a public page with JavaScript disabled
|
||||||
|
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
|
||||||
|
|
||||||
|
#### Scenario: Observer unavailable keeps reveals final
|
||||||
|
|
||||||
|
- **WHEN** the motion runtime executes without `IntersectionObserver`
|
||||||
|
- **THEN** reveal targets MUST remain in the final state
|
||||||
24
openspec/changes/enhance-public-motion/tasks.md
Normal file
24
openspec/changes/enhance-public-motion/tasks.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
## 1. Motion contract and runtime
|
||||||
|
|
||||||
|
- [x] 1.1 Extend feature tests first for motion tokens, declarative hooks, no-opacity text, safe fallbacks, one-shot observer, capped stagger and requestAnimationFrame coalescing
|
||||||
|
- [x] 1.2 Add centralized motion tokens and CSS states for page openings, grouped directional reveals, media clips and accessible interaction feedback
|
||||||
|
- [x] 1.3 Refactor `resources/js/motion.js` to enhance only when supported, initialize page beats/groups, unobserve revealed targets and coalesce progress updates per frame
|
||||||
|
|
||||||
|
## 2. Public template coverage
|
||||||
|
|
||||||
|
- [x] 2.1 Add failing markup coverage for home, services, portfolio index/detail, about, contact, privacy and branded 404/419/429/500/503 pages
|
||||||
|
- [x] 2.2 Apply page-open, beat, reveal-group and directional reveal hooks across all visual public templates without changing content or layout structure
|
||||||
|
- [x] 2.3 Add failing testimonial coverage, filter blank quotes before the rendered sequence and alternate explicit left/right directions on desktop and mobile
|
||||||
|
|
||||||
|
## 3. Browser and accessibility coverage
|
||||||
|
|
||||||
|
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
|
||||||
|
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
|
||||||
|
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
|
||||||
|
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
|
||||||
|
|
||||||
|
## 4. Verification and delivery
|
||||||
|
|
||||||
|
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
|
||||||
|
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
|
||||||
|
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree
|
||||||
@@ -119,6 +119,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||||
[data-chapter-index] {
|
[data-chapter-index] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -199,60 +203,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:where(a, button, input, textarea, select) {
|
||||||
|
transition-duration: var(--amare-motion-feedback);
|
||||||
|
transition-property: color, background-color, border-color, transform;
|
||||||
|
transition-timing-function: var(--amare-ease-arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a, button:not(:disabled)):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
|
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat] {
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
|
transform: translateY(var(--amare-motion-distance-y));
|
||||||
transform: translateY(0.5rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat="media"] {
|
||||||
transform: translateY(0.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
|
||||||
clip-path: inset(4% 0 0 0);
|
clip-path: inset(4% 0 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
html[data-motion="enhance"] [data-motion="page-open"].is-active [data-motion-beat] {
|
||||||
transform: none;
|
transform: none;
|
||||||
clip-path: inset(0 0 0 0);
|
clip-path: inset(0 0 0 0);
|
||||||
transition:
|
transition:
|
||||||
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||||
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||||
|
transition-delay: min(
|
||||||
|
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||||
|
var(--amare-motion-stagger-cap)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
html[data-motion="enhance"] [data-reveal] {
|
||||||
transition-delay: 0ms;
|
--motion-translate-x: 0;
|
||||||
|
--motion-translate-y: var(--amare-motion-distance-y);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||||
transition-delay: 80ms;
|
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile));
|
||||||
|
--motion-translate-y: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||||
transition-delay: 120ms;
|
--motion-translate-x: var(--amare-motion-distance-x-mobile);
|
||||||
|
--motion-translate-y: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
@media (min-width: 768px) {
|
||||||
transition-delay: 220ms;
|
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||||
|
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||||
|
--motion-translate-x: var(--amare-motion-distance-x);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
||||||
transform: translateY(0.5rem);
|
transform: translate3d(var(--motion-translate-x), var(--motion-translate-y), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal][data-reveal-media]:not(.is-revealed) {
|
||||||
|
clip-path: inset(3% 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal-group] [data-reveal].is-revealed,
|
||||||
html[data-motion="enhance"] [data-reveal].is-revealed {
|
html[data-motion="enhance"] [data-reveal].is-revealed {
|
||||||
transform: none;
|
transform: none;
|
||||||
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
clip-path: inset(0 0 0 0);
|
||||||
}
|
transition:
|
||||||
|
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||||
transform: translateY(0.4rem);
|
transition-delay: min(
|
||||||
}
|
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||||
|
var(--amare-motion-stagger-cap)
|
||||||
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
);
|
||||||
transform: none;
|
|
||||||
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,15 @@
|
|||||||
--amare-duration-focal: 720ms;
|
--amare-duration-focal: 720ms;
|
||||||
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
/* Editorial motion */
|
||||||
|
--amare-motion-enter: 500ms;
|
||||||
|
--amare-motion-feedback: 150ms;
|
||||||
|
--amare-motion-stagger: 90ms;
|
||||||
|
--amare-motion-stagger-cap: 270ms;
|
||||||
|
--amare-motion-distance-y: 12px;
|
||||||
|
--amare-motion-distance-x: 24px;
|
||||||
|
--amare-motion-distance-x-mobile: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -67,6 +76,13 @@
|
|||||||
--amare-duration-normal: 0.01ms;
|
--amare-duration-normal: 0.01ms;
|
||||||
--amare-duration-slow: 0.01ms;
|
--amare-duration-slow: 0.01ms;
|
||||||
--amare-duration-focal: 0.01ms;
|
--amare-duration-focal: 0.01ms;
|
||||||
|
--amare-motion-enter: 0.01ms;
|
||||||
|
--amare-motion-feedback: 0.01ms;
|
||||||
|
--amare-motion-stagger: 0ms;
|
||||||
|
--amare-motion-stagger-cap: 0ms;
|
||||||
|
--amare-motion-distance-y: 0px;
|
||||||
|
--amare-motion-distance-x: 0px;
|
||||||
|
--amare-motion-distance-x-mobile: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function prefersReducedMotion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enableEnhancement() {
|
function enableEnhancement() {
|
||||||
if (prefersReducedMotion()) {
|
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') {
|
||||||
document.documentElement.removeAttribute('data-motion');
|
document.documentElement.removeAttribute('data-motion');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -14,24 +14,6 @@ function enableEnhancement() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateHero(enhance) {
|
|
||||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
|
||||||
if (!hero) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activate = () => hero.classList.add('is-active');
|
|
||||||
|
|
||||||
if (!enhance) {
|
|
||||||
activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(activate);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function activatePageOpen(enhance) {
|
function activatePageOpen(enhance) {
|
||||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||||
if (!enhance) {
|
if (!enhance) {
|
||||||
@@ -45,6 +27,32 @@ function activatePageOpen(enhance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeMotionDelays() {
|
||||||
|
const beatOrder = new Map([
|
||||||
|
['seal', 0],
|
||||||
|
['heading', 0],
|
||||||
|
['title', 1],
|
||||||
|
['media', 2],
|
||||||
|
['cta', 3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-motion="page-open"]').forEach((page) => {
|
||||||
|
page.querySelectorAll('[data-motion-beat]').forEach((beat, index) => {
|
||||||
|
const namedIndex = beatOrder.get(beat.dataset.motionBeat);
|
||||||
|
beat.style.setProperty('--motion-index', String(Math.min(namedIndex ?? index, 3)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
|
||||||
|
const nodes = Array.from(group.querySelectorAll('[data-reveal]'))
|
||||||
|
.filter((node) => node.closest('[data-reveal-group]') === group);
|
||||||
|
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
node.style.setProperty('--motion-index', String(Math.min(index, 3)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function observeReveals(enhance) {
|
function observeReveals(enhance) {
|
||||||
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
@@ -86,6 +94,7 @@ function setupChapterIndex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||||
|
let framePending = false;
|
||||||
|
|
||||||
const setActive = (id) => {
|
const setActive = (id) => {
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
@@ -129,16 +138,28 @@ function setupChapterIndex() {
|
|||||||
updateProgress(window.scrollY / max);
|
updateProgress(window.scrollY / max);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleSync = () => {
|
||||||
|
if (framePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
framePending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
framePending = false;
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||||
sync();
|
sync();
|
||||||
|
|
||||||
window.addEventListener('scroll', sync, { passive: true });
|
window.addEventListener('scroll', scheduleSync, { passive: true });
|
||||||
window.addEventListener('resize', sync);
|
window.addEventListener('resize', scheduleSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootMotion() {
|
function bootMotion() {
|
||||||
|
initializeMotionDelays();
|
||||||
const enhance = enableEnhancement();
|
const enhance = enableEnhancement();
|
||||||
activateHero(enhance);
|
|
||||||
activatePageOpen(enhance);
|
activatePageOpen(enhance);
|
||||||
observeReveals(enhance);
|
observeReveals(enhance);
|
||||||
setupChapterIndex();
|
setupChapterIndex();
|
||||||
@@ -146,7 +167,7 @@ function bootMotion() {
|
|||||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||||
if (event.matches) {
|
if (event.matches) {
|
||||||
document.documentElement.removeAttribute('data-motion');
|
document.documentElement.removeAttribute('data-motion');
|
||||||
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||||
node.classList.add('is-active');
|
node.classList.add('is-active');
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta" data-reveal-group>
|
||||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
aria-labelledby="hero-heading"
|
aria-labelledby="hero-heading"
|
||||||
class="border-b border-amare-border bg-amare-bg"
|
class="border-b border-amare-border bg-amare-bg"
|
||||||
data-chapter="hero"
|
data-chapter="hero"
|
||||||
data-motion="dossie-hero"
|
data-motion="page-open"
|
||||||
>
|
>
|
||||||
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28" data-reveal-group>
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent" data-reveal data-reveal-from="up">Manifesto</p>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6" data-reveal data-reveal-from="up">
|
||||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start" data-reveal-group>
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<ol class="grid gap-5 border-t border-amare-border">
|
<ol class="grid gap-5 border-t border-amare-border">
|
||||||
@foreach ($steps as $index => $step)
|
@foreach ($steps as $index => $step)
|
||||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
|
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
@if ($cases->isNotEmpty())
|
@if ($cases->isNotEmpty())
|
||||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2">
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
|
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
|
||||||
@if (filled($case->cover_image_path))
|
@if (filled($case->cover_image_path))
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8" data-reveal>
|
<div class="space-y-8" data-reveal data-reveal-from="up">
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||||
<ul class="grid gap-3 border-t border-amare-border">
|
<ul class="grid gap-3 border-t border-amare-border">
|
||||||
@foreach ($principles as $principle)
|
@foreach ($principles as $principle)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
@if ($services->isNotEmpty())
|
@if ($services->isNotEmpty())
|
||||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
||||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<ol class="border-t border-amare-border">
|
<ol class="border-t border-amare-border">
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
|
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
||||||
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
||||||
|
|||||||
@@ -2,22 +2,27 @@
|
|||||||
'testimonials',
|
'testimonials',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$testimonials = $testimonials
|
||||||
|
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||||
|
->values();
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($testimonials->isNotEmpty())
|
@if ($testimonials->isNotEmpty())
|
||||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
||||||
<div class="container-amare space-y-8">
|
<div class="container-amare space-y-8" data-reveal-group>
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
@foreach ($testimonials as $testimonial)
|
@foreach ($testimonials as $testimonial)
|
||||||
@continue(blank(trim((string) $testimonial->quote)))
|
|
||||||
@php
|
@php
|
||||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||||
@endphp
|
@endphp
|
||||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
|
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||||
<div class="space-y-3 text-lg text-amare-text">
|
<div class="space-y-3 text-lg text-amare-text">
|
||||||
@foreach ($paragraphs as $index => $paragraph)
|
@foreach ($paragraphs as $index => $paragraph)
|
||||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 404</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 404</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||||
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 419</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
|
||||||
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 429</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Muitas solicitações</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
|
||||||
<p class="text-amare-muted">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 500</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 500</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||||
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 503</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
|
||||||
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
||||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||||
@@ -32,9 +32,9 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
|
||||||
@foreach ($principles as $index => $principle)
|
@foreach ($principles as $index => $principle)
|
||||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text">
|
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<span>{{ $principle }}</span>
|
<span>{{ $principle }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
||||||
<p class="max-w-2xl text-lg text-amare-muted">
|
<p class="max-w-2xl text-lg text-amare-muted">
|
||||||
@@ -11,21 +11,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10">
|
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10" data-reveal-group>
|
||||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
<p data-reveal data-reveal-from="up">{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
||||||
<div class="container-amare py-16 md:py-24">
|
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl" data-reveal data-reveal-from="up">Briefing de contato</h2>
|
||||||
<p class="mt-2 max-w-2xl text-amare-muted">
|
<p class="mt-2 max-w-2xl text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
@php
|
||||||
|
$testimonials = $content->testimonials
|
||||||
|
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||||
|
->values();
|
||||||
|
|
||||||
$chapters = [
|
$chapters = [
|
||||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
|
|
||||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||||
|
|
||||||
if ($content->testimonials->isNotEmpty()) {
|
if ($testimonials->isNotEmpty()) {
|
||||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
<x-home.services :services="$content->featuredServices" />
|
<x-home.services :services="$content->featuredServices" />
|
||||||
<x-home.portfolio :cases="$content->featuredCases" />
|
<x-home.portfolio :cases="$content->featuredCases" />
|
||||||
<x-home.method :settings="$content->settings" />
|
<x-home.method :settings="$content->settings" />
|
||||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
<x-home.testimonials :testimonials="$testimonials" />
|
||||||
<x-home.positioning :settings="$content->settings" />
|
<x-home.positioning :settings="$content->settings" />
|
||||||
<x-home.final-cta :settings="$content->settings" />
|
<x-home.final-cta :settings="$content->settings" />
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
||||||
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
@if ($cases->isEmpty())
|
@if ($cases->isEmpty())
|
||||||
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2" data-reveal-group>
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
<article class="space-y-4">
|
<article class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
@if (filled($case->cover_image_path))
|
@if (filled($case->cover_image_path))
|
||||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
||||||
<x-media.image
|
<x-media.image
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<article data-motion="page-open">
|
<article data-motion="page-open">
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||||
<header class="max-w-3xl space-y-4">
|
<header class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||||
@if (filled($case->event_type))
|
@if (filled($case->event_type))
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -24,23 +24,24 @@
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
sizes="(max-width: 1024px) 100vw, 1120px"
|
sizes="(max-width: 1024px) 100vw, 1120px"
|
||||||
class="img-editorial aspect-[16/9] w-full object-cover"
|
class="img-editorial aspect-[16/9] w-full object-cover"
|
||||||
|
data-motion-beat="media"
|
||||||
/>
|
/>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||||
<div class="container-amare grid gap-10 py-16 md:grid-cols-3">
|
<div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
@if (filled($case->result))
|
@if (filled($case->result))
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -52,9 +53,9 @@
|
|||||||
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16">
|
<div class="container-amare space-y-8 py-16">
|
||||||
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2" data-reveal-group>
|
||||||
@foreach ($case->images as $image)
|
@foreach ($case->images as $image)
|
||||||
<figure class="space-y-2">
|
<figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$image->path"
|
:path="$image->path"
|
||||||
:alt="$image->alt_text"
|
:alt="$image->alt_text"
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24">
|
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Privacidade</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Privacidade</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Política de privacidade</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Política de privacidade</h1>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
|
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
|
||||||
relacionadas ao atendimento de interessados e à operação do site.
|
relacionadas ao atendimento de interessados e à operação do site.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
Para dúvidas sobre privacidade, escreva para
|
Para dúvidas sobre privacidade, escreva para
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||||
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
@if ($services->isEmpty())
|
@if ($services->isEmpty())
|
||||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||||
@else
|
@else
|
||||||
<div class="divide-y divide-amare-border border-y border-amare-border">
|
<div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start">
|
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,6 +23,10 @@ it('has no critical or serious accessibility issues on covered public routes', f
|
|||||||
'/servicos',
|
'/servicos',
|
||||||
'/portfolio',
|
'/portfolio',
|
||||||
'/portfolio/'.$case->slug,
|
'/portfolio/'.$case->slug,
|
||||||
|
'/sobre',
|
||||||
|
'/contato',
|
||||||
|
'/privacidade',
|
||||||
|
'/__missing-accessibility-page',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($routes as $route) {
|
foreach ($routes as $route) {
|
||||||
@@ -88,7 +92,16 @@ it('loads covered public routes without console errors', function (): void {
|
|||||||
'slug' => 'casamento-jardim',
|
'slug' => 'casamento-jardim',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (['/', '/servicos', '/portfolio', '/portfolio/'.$case->slug] as $route) {
|
foreach ([
|
||||||
|
'/',
|
||||||
|
'/servicos',
|
||||||
|
'/portfolio',
|
||||||
|
'/portfolio/'.$case->slug,
|
||||||
|
'/sobre',
|
||||||
|
'/contato',
|
||||||
|
'/privacidade',
|
||||||
|
'/__missing-console-page',
|
||||||
|
] as $route) {
|
||||||
$this->visit($route)
|
$this->visit($route)
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@@ -21,24 +23,28 @@ beforeEach(function (): void {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps home motion in final visible state when reduced motion is preferred', function (): void {
|
it('keeps every home motion target in final visible state when reduced motion is preferred', function (): void {
|
||||||
$page = $this->visit('/', [
|
$page = $this->visit('/', [
|
||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$state = $page->script(<<<'JS'
|
$state = $page->script(<<<'JS'
|
||||||
() => {
|
() => {
|
||||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
const hero = document.querySelector('[data-motion="page-open"]');
|
||||||
const title = document.querySelector('#hero-heading');
|
const title = document.querySelector('#hero-heading');
|
||||||
const index = document.querySelector('[data-chapter-index]');
|
const index = document.querySelector('[data-chapter-index]');
|
||||||
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
|
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
|
||||||
const titleStyle = title ? getComputedStyle(title) : null;
|
const titleStyle = title ? getComputedStyle(title) : null;
|
||||||
|
const finalReveals = [...document.querySelectorAll('[data-reveal]')]
|
||||||
|
.every((node) => node.classList.contains('is-revealed'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
heroActive: hero?.classList.contains('is-active') ?? false,
|
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||||
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||||
hasIndex: Boolean(index),
|
hasIndex: Boolean(index),
|
||||||
hasActiveChapter: Boolean(active),
|
hasActiveChapter: Boolean(active),
|
||||||
|
finalReveals,
|
||||||
|
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
JS);
|
JS);
|
||||||
@@ -47,9 +53,11 @@ it('keeps home motion in final visible state when reduced motion is preferred',
|
|||||||
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
||||||
expect($state['hasIndex'])->toBeTrue();
|
expect($state['hasIndex'])->toBeTrue();
|
||||||
expect($state['hasActiveChapter'])->toBeTrue();
|
expect($state['hasActiveChapter'])->toBeTrue();
|
||||||
|
expect($state['finalReveals'])->toBeTrue();
|
||||||
|
expect($state['enhancementEnabled'])->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('activates the home focal opening once when motion is allowed', function (): void {
|
it('activates the home opening with 500ms beats and capped 90ms stagger', function (): void {
|
||||||
$page = $this->visit('/', [
|
$page = $this->visit('/', [
|
||||||
'reducedMotion' => 'no-preference',
|
'reducedMotion' => 'no-preference',
|
||||||
]);
|
]);
|
||||||
@@ -57,7 +65,7 @@ it('activates the home focal opening once when motion is allowed', function ():
|
|||||||
$page->script(<<<'JS'
|
$page->script(<<<'JS'
|
||||||
() => new Promise((resolve) => {
|
() => new Promise((resolve) => {
|
||||||
const wait = () => {
|
const wait = () => {
|
||||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
const hero = document.querySelector('[data-motion="page-open"]');
|
||||||
if (hero?.classList.contains('is-active')) {
|
if (hero?.classList.contains('is-active')) {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
return;
|
return;
|
||||||
@@ -69,11 +77,186 @@ it('activates the home focal opening once when motion is allowed', function ():
|
|||||||
})
|
})
|
||||||
JS);
|
JS);
|
||||||
|
|
||||||
$active = $page->script(<<<'JS'
|
$state = $page->script(<<<'JS'
|
||||||
() => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
|
() => {
|
||||||
|
const hero = document.querySelector('[data-motion="page-open"]');
|
||||||
|
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
|
||||||
|
.map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration));
|
||||||
|
const methodItems = [...document.querySelectorAll('[data-chapter="method"] [data-reveal]')];
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: hero?.classList.contains('is-active') ?? false,
|
||||||
|
beatDurations,
|
||||||
|
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')),
|
||||||
|
};
|
||||||
|
}
|
||||||
JS);
|
JS);
|
||||||
|
|
||||||
expect($active)->toBeTrue();
|
expect($state['active'])->toBeTrue();
|
||||||
|
expect($state['beatDurations'])->each->toBe(0.5);
|
||||||
|
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals a section once and keeps it final after returning in the scroll', function (): void {
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$before = $page->script(<<<'JS'
|
||||||
|
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => {
|
||||||
|
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => new Promise((resolve) => {
|
||||||
|
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]');
|
||||||
|
const wait = () => {
|
||||||
|
if (target?.classList.contains('is-revealed')) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(wait);
|
||||||
|
};
|
||||||
|
wait();
|
||||||
|
})
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
|
||||||
|
|
||||||
|
$after = $page->script(<<<'JS'
|
||||||
|
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($before)->toBeFalse();
|
||||||
|
expect($after)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void {
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'quote' => 'Primeiro relato.',
|
||||||
|
'sort_order' => 10,
|
||||||
|
]);
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'quote' => 'Segundo relato.',
|
||||||
|
'sort_order' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$readDistances = <<<'JS'
|
||||||
|
() => [...document.querySelectorAll('[data-chapter="testimonials"] blockquote')]
|
||||||
|
.map((item) => ({
|
||||||
|
direction: item.dataset.revealFrom,
|
||||||
|
distance: getComputedStyle(item).getPropertyValue('--motion-translate-x').trim(),
|
||||||
|
}))
|
||||||
|
JS;
|
||||||
|
|
||||||
|
$page->resize(1440, 1000);
|
||||||
|
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||||
|
$desktop = $page->script($readDistances);
|
||||||
|
|
||||||
|
$page->resize(390, 844);
|
||||||
|
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||||
|
$mobile = $page->script($readDistances);
|
||||||
|
|
||||||
|
expect($desktop)->toBe([
|
||||||
|
['direction' => 'left', 'distance' => 'calc(-1 * 24px)'],
|
||||||
|
['direction' => 'right', 'distance' => '24px'],
|
||||||
|
]);
|
||||||
|
expect($mobile)->toBe([
|
||||||
|
['direction' => 'left', 'distance' => 'calc(-1 * 16px)'],
|
||||||
|
['direction' => 'right', 'distance' => '16px'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps content and navigation usable without javascript', function (): void {
|
||||||
|
$this->visit('/', [
|
||||||
|
'javaScriptEnabled' => false,
|
||||||
|
])
|
||||||
|
->assertVisible('#hero-heading')
|
||||||
|
->assertVisible('[data-testid="home-primary-cta"]')
|
||||||
|
->click('[data-testid="home-primary-cta"]')
|
||||||
|
->assertPathIs('/contato');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps targets final when intersection observer is unavailable', function (): void {
|
||||||
|
$page = $this->visit('/');
|
||||||
|
$rawPage = $page->page();
|
||||||
|
$url = $rawPage->url();
|
||||||
|
|
||||||
|
$rawPage->context()->addInitScript('window.IntersectionObserver = undefined;');
|
||||||
|
$rawPage->goto($url);
|
||||||
|
|
||||||
|
$state = $page->script(<<<'JS'
|
||||||
|
() => ({
|
||||||
|
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||||
|
pagesFinal: [...document.querySelectorAll('[data-motion="page-open"]')]
|
||||||
|
.every((node) => node.classList.contains('is-active')),
|
||||||
|
revealsFinal: [...document.querySelectorAll('[data-reveal]')]
|
||||||
|
.every((node) => node.classList.contains('is-revealed')),
|
||||||
|
})
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($state)->toBe([
|
||||||
|
'enhancementEnabled' => false,
|
||||||
|
'pagesFinal' => true,
|
||||||
|
'revealsFinal' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cta activation while its opening is in progress', function (): void {
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => {
|
||||||
|
const opening = document.querySelector('[data-motion="page-open"]');
|
||||||
|
opening?.classList.remove('is-active');
|
||||||
|
requestAnimationFrame(() => opening?.classList.add('is-active'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->click('[data-testid="home-primary-cta"]')
|
||||||
|
->assertPathIs('/contato');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
|
||||||
|
$case = PortfolioCase::factory()->published()->create([
|
||||||
|
'slug' => 'casamento-jardim',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routes = [
|
||||||
|
'/',
|
||||||
|
'/servicos',
|
||||||
|
'/portfolio',
|
||||||
|
'/portfolio/'.$case->slug,
|
||||||
|
'/sobre',
|
||||||
|
'/contato',
|
||||||
|
'/privacidade',
|
||||||
|
'/__missing-public-motion',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$page = $this->visit($route, [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
])->resize($width, $height);
|
||||||
|
|
||||||
|
$overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth');
|
||||||
|
|
||||||
|
expect($overflow)->toBeLessThanOrEqual(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates the active chapter while scrolling the home dossier', function (): void {
|
it('updates the active chapter while scrolling the home dossier', function (): void {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ $screens = [
|
|||||||
'services' => '/servicos',
|
'services' => '/servicos',
|
||||||
'portfolio' => '/portfolio',
|
'portfolio' => '/portfolio',
|
||||||
'portfolio-detail' => '/portfolio/casamento-ana-lucas',
|
'portfolio-detail' => '/portfolio/casamento-ana-lucas',
|
||||||
|
'about' => '/sobre',
|
||||||
|
'contact' => '/contato',
|
||||||
|
'privacy' => '/privacidade',
|
||||||
|
'not-found' => '/__missing-visual-page',
|
||||||
];
|
];
|
||||||
|
|
||||||
$viewports = [
|
$viewports = [
|
||||||
|
|||||||
@@ -56,4 +56,25 @@ class ErrorPagesTest extends TestCase
|
|||||||
->assertDontSee('segredo-interno-amare')
|
->assertDontSee('segredo-interno-amare')
|
||||||
->assertDontSee('Exception');
|
->assertDontSee('Exception');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_all_branded_error_pages_expose_progressive_motion_hooks(): void
|
||||||
|
{
|
||||||
|
foreach ([419, 429, 500, 503] as $status) {
|
||||||
|
Route::get('/__test-motion-'.$status, fn (): never => abort($status));
|
||||||
|
|
||||||
|
$this->get('/__test-motion-'.$status)
|
||||||
|
->assertStatus($status)
|
||||||
|
->assertSee('data-motion="page-open"', false)
|
||||||
|
->assertSee('data-motion-beat', false)
|
||||||
|
->assertSee('data-reveal-group', false)
|
||||||
|
->assertSee('data-reveal', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->get('/__missing-motion-page')
|
||||||
|
->assertNotFound()
|
||||||
|
->assertSee('data-motion="page-open"', false)
|
||||||
|
->assertSee('data-motion-beat', false)
|
||||||
|
->assertSee('data-reveal-group', false)
|
||||||
|
->assertSee('data-reveal', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,55 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Autor Mantido')
|
->assertSee('Autor Mantido')
|
||||||
->assertSee('Experiência impecável do início ao fim.')
|
->assertSee('Experiência impecável do início ao fim.')
|
||||||
->assertDontSee('Autor Oculto');
|
->assertDontSee('Autor Oculto')
|
||||||
|
->assertSee('data-reveal-from="left"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blank_testimonials_are_filtered_before_directional_sequence(): void
|
||||||
|
{
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'author_name' => 'Primeira autora',
|
||||||
|
'quote' => 'Primeiro relato.',
|
||||||
|
'sort_order' => 10,
|
||||||
|
]);
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'author_name' => 'Relato vazio',
|
||||||
|
'quote' => " \n ",
|
||||||
|
'sort_order' => 20,
|
||||||
|
]);
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'author_name' => 'Segunda autora',
|
||||||
|
'quote' => 'Segundo relato.',
|
||||||
|
'sort_order' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Relato vazio')
|
||||||
|
->assertSeeInOrder([
|
||||||
|
'data-reveal-from="left"',
|
||||||
|
'Primeira autora',
|
||||||
|
'data-reveal-from="right"',
|
||||||
|
'Segunda autora',
|
||||||
|
], false);
|
||||||
|
|
||||||
|
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
|
||||||
|
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
|
||||||
|
{
|
||||||
|
Testimonial::factory()->published()->create([
|
||||||
|
'author_name' => 'Relato vazio',
|
||||||
|
'quote' => ' ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('id="testimonials-heading"', false)
|
||||||
|
->assertDontSee('href="#testimonials-heading"', false)
|
||||||
|
->assertDontSee('Relato vazio');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class MotionMarkupTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_home_exposes_focal_hero_hooks_and_chapter_index_links(): void
|
public function test_home_exposes_page_open_beats_reveal_groups_and_chapter_index_links(): void
|
||||||
{
|
{
|
||||||
$settings = SiteSetting::instance();
|
$settings = SiteSetting::instance();
|
||||||
$settings->update([
|
$settings->update([
|
||||||
@@ -35,11 +35,13 @@ class MotionMarkupTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('data-motion="dossie-hero"', false)
|
->assertSee('data-motion="page-open"', false)
|
||||||
->assertSee('data-motion-beat="seal"', false)
|
->assertSee('data-motion-beat="seal"', false)
|
||||||
->assertSee('data-motion-beat="title"', false)
|
->assertSee('data-motion-beat="title"', false)
|
||||||
->assertSee('data-motion-beat="media"', false)
|
->assertSee('data-motion-beat="media"', false)
|
||||||
->assertSee('data-motion-beat="cta"', false)
|
->assertSee('data-motion-beat="cta"', false)
|
||||||
|
->assertSee('data-reveal-group', false)
|
||||||
|
->assertSee('data-reveal-from="up"', false)
|
||||||
->assertSee('data-chapter-index', false)
|
->assertSee('data-chapter-index', false)
|
||||||
->assertSee('data-chapter-progress', false)
|
->assertSee('data-chapter-progress', false)
|
||||||
->assertSee('href="#hero-heading"', false)
|
->assertSee('href="#hero-heading"', false)
|
||||||
@@ -83,18 +85,28 @@ class MotionMarkupTest extends TestCase
|
|||||||
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
||||||
$appJs = (string) file_get_contents(resource_path('js/app.js'));
|
$appJs = (string) file_get_contents(resource_path('js/app.js'));
|
||||||
|
|
||||||
$this->assertStringContainsString('--amare-duration-focal:', $tokens);
|
$this->assertStringContainsString('--amare-motion-enter: 500ms', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-feedback: 150ms', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-stagger: 90ms', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-stagger-cap: 270ms', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-distance-y: 12px', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-distance-x: 24px', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-motion-distance-x-mobile: 16px', $tokens);
|
||||||
$this->assertStringContainsString('--amare-ease-arrival:', $tokens);
|
$this->assertStringContainsString('--amare-ease-arrival:', $tokens);
|
||||||
$this->assertStringContainsString('--amare-duration-focal: 0.01ms', $tokens);
|
$this->assertStringContainsString('--amare-motion-enter: 0.01ms', $tokens);
|
||||||
|
|
||||||
$this->assertStringContainsString('[data-motion="dossie-hero"]', $appCss);
|
$this->assertStringContainsString('[data-motion="page-open"]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-motion-beat]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-reveal-group]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-reveal-from="left"]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-reveal-from="right"]', $appCss);
|
||||||
$this->assertStringContainsString('[data-reveal]', $appCss);
|
$this->assertStringContainsString('[data-reveal]', $appCss);
|
||||||
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
||||||
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
||||||
|
|
||||||
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
||||||
$this->assertDoesNotMatchRegularExpression(
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
'/\[data-motion-beat="seal"\][^{]*\{[^}]*opacity:\s*0/s',
|
'/\[data-motion-beat\][^{]*\{[^}]*opacity:\s*0/s',
|
||||||
$appCss,
|
$appCss,
|
||||||
);
|
);
|
||||||
$this->assertDoesNotMatchRegularExpression(
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
@@ -108,10 +120,17 @@ class MotionMarkupTest extends TestCase
|
|||||||
|
|
||||||
$this->assertFileExists(resource_path('js/motion.js'));
|
$this->assertFileExists(resource_path('js/motion.js'));
|
||||||
$this->assertStringContainsString("import './motion.js'", $appJs);
|
$this->assertStringContainsString("import './motion.js'", $appJs);
|
||||||
$this->assertStringContainsString('prefers-reduced-motion', (string) file_get_contents(resource_path('js/motion.js')));
|
$motionJs = (string) file_get_contents(resource_path('js/motion.js'));
|
||||||
|
$this->assertStringContainsString('prefers-reduced-motion', $motionJs);
|
||||||
|
$this->assertStringContainsString("querySelectorAll('[data-reveal-group]')", $motionJs);
|
||||||
|
$this->assertStringContainsString('Math.min(index, 3)', $motionJs);
|
||||||
|
$this->assertStringContainsString('observer.unobserve(entry.target)', $motionJs);
|
||||||
|
$this->assertStringContainsString("typeof IntersectionObserver !== 'function'", $motionJs);
|
||||||
|
$this->assertStringContainsString('requestAnimationFrame', $motionJs);
|
||||||
|
$this->assertStringContainsString('framePending', $motionJs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_internal_pages_expose_shared_page_open_hook(): void
|
public function test_all_visual_public_pages_expose_shared_opening_and_reveal_hooks(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance();
|
SiteSetting::instance();
|
||||||
$case = PortfolioCase::factory()->published()->create([
|
$case = PortfolioCase::factory()->published()->create([
|
||||||
@@ -119,28 +138,38 @@ class MotionMarkupTest extends TestCase
|
|||||||
'title' => 'Casamento Jardim',
|
'title' => 'Casamento Jardim',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get(route('services.index'))
|
$responses = [
|
||||||
->assertOk()
|
$this->get(route('home')),
|
||||||
->assertSee('data-motion="page-open"', false);
|
$this->get(route('services.index')),
|
||||||
|
$this->get(route('portfolio.index')),
|
||||||
|
$this->get(route('portfolio.show', $case->slug)),
|
||||||
|
$this->get(route('about')),
|
||||||
|
$this->get(route('contact')),
|
||||||
|
$this->get(route('privacy')),
|
||||||
|
];
|
||||||
|
|
||||||
$this->get(route('portfolio.index'))
|
foreach ($responses as $response) {
|
||||||
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('data-motion="page-open"', false);
|
->assertSee('data-motion="page-open"', false)
|
||||||
|
->assertSee('data-motion-beat', false)
|
||||||
$this->get(route('portfolio.show', $case->slug))
|
->assertSee('data-reveal-group', false)
|
||||||
->assertOk()
|
->assertSee('data-reveal', false);
|
||||||
->assertSee('data-motion="page-open"', false);
|
}
|
||||||
|
}
|
||||||
$this->get(route('about'))
|
|
||||||
->assertOk()
|
public function test_technical_endpoints_do_not_expose_motion_contract(): void
|
||||||
->assertSee('data-motion="page-open"', false);
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
$this->get(route('contact'))
|
|
||||||
->assertOk()
|
$this->get(route('sitemap'))
|
||||||
->assertDontSee('data-motion="page-open"', false);
|
->assertOk()
|
||||||
|
->assertDontSee('data-motion=', false)
|
||||||
$this->get(route('privacy'))
|
->assertDontSee('data-reveal', false);
|
||||||
->assertOk()
|
|
||||||
->assertDontSee('data-motion="page-open"', false);
|
$this->get(route('robots'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('data-motion=', false)
|
||||||
|
->assertDontSee('data-reveal', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ trait StableScreenshot
|
|||||||
.' && document.fonts.status === "loaded"'
|
.' && document.fonts.status === "loaded"'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$page->evaluate(<<<'JS'
|
||||||
|
async () => {
|
||||||
|
const viewport = Math.max(window.innerHeight, 1);
|
||||||
|
|
||||||
|
for (let top = 0; top < document.documentElement.scrollHeight; top += viewport) {
|
||||||
|
window.scrollTo(0, top);
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([...document.images].map(async (image) => {
|
||||||
|
if (! image.complete) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
image.addEventListener('load', resolve, { once: true });
|
||||||
|
image.addEventListener('error', resolve, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await image.decode().catch(() => {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
usleep(300_000);
|
usleep(300_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user