@breathe-ds/ui 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -7
- package/components/AnchorNav.tsx +12 -4
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +3 -1
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +47 -10
- package/components/Header.tsx +4 -2
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +155 -50
- package/components/Rail.tsx +94 -0
- package/components/Rows.tsx +9 -3
- package/components/StoryPanel.tsx +107 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +13 -2
- package/theme.css +140 -0
- package/tokens/creeta.css +2 -0
- package/tokens/neutral.css +2 -0
- package/tokens/ttcab.css +13 -5
package/README.md
CHANGED
|
@@ -69,7 +69,17 @@ hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
|
|
|
69
69
|
| `ink-body` | reading body text | `--cg-ink-body` |
|
|
70
70
|
| `action` | CTA / highlight — **never plain text** | `--cg-action` |
|
|
71
71
|
| `action-text` | text in the action color (AA) | `--cg-action-text` |
|
|
72
|
+
| `on-action` | text ON the action color (the accent button) | `--cg-on-action` |
|
|
73
|
+
| `action-tint` | highlighted panel or card: a 6% wash of the action color over the surface | `--cg-action-tint` |
|
|
74
|
+
| `action-line` | border of a highlighted card (closed tone, 3:1 on the surface; pure ttcab orange is 1.91:1) | `--cg-action-line` |
|
|
72
75
|
| `line` | hairlines / borders | `--cg-line` |
|
|
76
|
+
|
|
77
|
+
**Dose of the action color** (what brands with a hot accent do, Cloudflare orange,
|
|
78
|
+
Airbnb red): the action color is the fill of ONE element per block, with
|
|
79
|
+
`on-action` text on it; links stay in `ink`/`ink-soft` with an underline; a
|
|
80
|
+
highlighted card is `action-tint` + `action-line`, not the full color; the
|
|
81
|
+
color as text only as `action-text`. The "Action dose" section of the playground
|
|
82
|
+
shows the disciplined and the loud version side by side.
|
|
73
83
|
| `font-heading` / `font-body` | typography | `--cg-font-*` |
|
|
74
84
|
| `radius-xl/2xl/pill` · `ease-cg` | radius and motion curve | fixed |
|
|
75
85
|
|
|
@@ -82,10 +92,11 @@ Values from the standard WCAG formula (the same ones `preview/` computes live):
|
|
|
82
92
|
| surface `#fbf5e9` / ink `#144753` | 9.41:1 | AAA |
|
|
83
93
|
| surface `#fbf5e9` / ink-soft `#54707a` | 4.86:1 | AA |
|
|
84
94
|
| surface `#fbf5e9` / ink-body `#1e4f5b` | 8.32:1 | AAA |
|
|
85
|
-
| surface `#fbf5e9` / action-text `#
|
|
95
|
+
| surface `#fbf5e9` / action-text `#bd3f0c` | 4.98:1 | AA |
|
|
96
|
+
| action `#e8541e` / on-action `#ffffff` (accent button) | 3.67:1 | 3:1 UI component; below AA text, conscious exception |
|
|
86
97
|
| surface-panel `#144753` / cream text | 9.41:1 | AAA |
|
|
87
98
|
|
|
88
|
-
`action` `#
|
|
99
|
+
`action` `#e8541e` is a vivid red-orange with white text, the Cloudflare/Airbnb route: 3.67:1 passes the 3:1 of UI components, not the 4.5:1 of normal text. A conscious exception, limited to the accent Button (one per block, same type scale as the other buttons so they never look mismatched); small controls (chips, tabs, segments) use `ink` or `action-tint` + `action-text` instead. The UI orange does not match the logo's `#f6a22a` on purpose. As text, use `action-text`.
|
|
89
100
|
|
|
90
101
|
## Family typography
|
|
91
102
|
|
|
@@ -200,9 +211,17 @@ from color, not from extra markup.
|
|
|
200
211
|
replace the arrow). The ProfessionalCard is built on them.
|
|
201
212
|
- **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
|
|
202
213
|
novo.creeta — portrait (plain, living clip on hover, or with the "Presentation"
|
|
203
|
-
chip), name, role, registration (**always**: COFFITO 414/2012)
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
chip), name, role, registration (**always**: COFFITO 414/2012) and generic
|
|
215
|
+
`rows` (collapse with text or links, link with arrow or icon — on creeta:
|
|
216
|
+
trajectory, guidance, Instagram); the modal plays the presentation with sound,
|
|
217
|
+
the live caption band (active WebVTT cue) above the name, and the full `bio`.
|
|
218
|
+
Three sizes (`size`): `full` (team page), `medium` (the home's tile — 3:4
|
|
219
|
+
portrait, name, "Meet" badge on hover, a link to the full card; `shortName`,
|
|
220
|
+
`href`) and `micro` (the "who treats" row — 72px photo, name, role, link).
|
|
221
|
+
- **Rail** / **RailItem**: novo.creeta's `Trilho` — on the phone a snapping
|
|
222
|
+
horizontal rail with a progress bar underneath (shown only when it scrolls),
|
|
223
|
+
a grid from md (`grid` classes written in full). RailItem presets the phone
|
|
224
|
+
width: `wide` (treatments, videos) or `narrow` (team).
|
|
206
225
|
- **Byline** / **PersonChip** / **Avatar**: who signs or explains content
|
|
207
226
|
(novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
|
|
208
227
|
+ "Watch the video ↗"; the person chip (portrait, name, role · registration,
|
|
@@ -234,9 +253,33 @@ from color, not from extra markup.
|
|
|
234
253
|
- **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
|
|
235
254
|
card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
|
|
236
255
|
name over it, pause control, play button that plays the full video with sound.
|
|
256
|
+
- **CareSteps**: "how the care works" of novo.creeta — a bento mosaic of
|
|
257
|
+
numbered steps (drawing above, text below; the first takes two columns) with
|
|
258
|
+
the invite on the dark panel closing the sequence. The drawings are slots:
|
|
259
|
+
**ChatPreview** (a sample chat, bubble by bubble, the clinic "types"; loops
|
|
260
|
+
while on screen, pause button), **PlanTimeline** (three stages, the line comes
|
|
261
|
+
down and each dot lights up; 6s loop, pause button), **PhotoSequence** (photos
|
|
262
|
+
with fade and a slow zoom, CSS only, paused by a hidden checkbox), or a still
|
|
263
|
+
image. All stand still and whole with reduced motion. `useOnAppear` (animate
|
|
264
|
+
when half on screen, only while on screen) and `PauseButton` (44px, 32px
|
|
265
|
+
circle) are exported for your own drawings.
|
|
266
|
+
- **Blog**: creeta's "guidance" area — **PostCard** (the card of the list:
|
|
267
|
+
cover, category, title as the link stretched over the card, excerpt in three
|
|
268
|
+
lines, author · date), **FeaturedPosts** ("in the words of who treats you":
|
|
269
|
+
bento, the first post wide with the cover beside the text, a Byline with the
|
|
270
|
+
speaker and "Watch the video ↗" at the foot) and **PostArticle** (the page:
|
|
271
|
+
trail, title, PersonChip and date, the text with its notice, the video beside
|
|
272
|
+
it sticky from md, the transcript open; children for "other posts" and the
|
|
273
|
+
final call).
|
|
274
|
+
- **StoryPanel**: creeta's "origin" block — one dark panel (surface-panel)
|
|
275
|
+
grouping a story: media at the top with a film that fades into the panel, the
|
|
276
|
+
logo at its base, title, lead, three pillars in columns with a hairline above
|
|
277
|
+
each, and the outbound links. For "who we are" and "why us" blocks.
|
|
237
278
|
- **Footer**: the site-footer of novo.creeta, by tokens — full width like the
|
|
238
279
|
Header, with the signed brand + sentence, "Pages" (title on the first line, links in two
|
|
239
|
-
columns
|
|
280
|
+
columns; with many pages, `pages.groups` — titled columns, up to four side by
|
|
281
|
+
side, so the list gets wider instead of taller), "Where we are" (address, hours,
|
|
282
|
+
"Directions ↗ · Waze ↗"), an optional
|
|
240
283
|
notice band on `surface`, the legal line with the **CNPJ (always required in
|
|
241
284
|
the family)** plus the contact chips, and the languages as plain links.
|
|
242
285
|
`navHiddenFrom` keeps room at the bottom for the BottomNav.
|
|
@@ -264,7 +307,7 @@ from color, not from extra markup.
|
|
|
264
307
|
**Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
|
|
265
308
|
- [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
|
|
266
309
|
- [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
|
|
267
|
-
- [x] **Footer** · **PageVideoHero** (loop and player) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
310
|
+
- [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
268
311
|
- [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
|
|
269
312
|
- [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
|
|
270
313
|
**already exists in ttcab** → extract.
|
package/components/AnchorNav.tsx
CHANGED
|
@@ -36,7 +36,8 @@ type Props = {
|
|
|
36
36
|
* The active signal = the indicator (ink/12 fill + outline), NOT text color:
|
|
37
37
|
* the text is always `ink` on `surface`, with no low-contrast frame.
|
|
38
38
|
* prefers-reduced-motion removes the slide. Target sections need a
|
|
39
|
-
* `scroll-mt-*`
|
|
39
|
+
* `scroll-mt-*` that, added to the theme's `scroll-padding-top` (96px), clears
|
|
40
|
+
* the header plus the capsule (e.g. `scroll-mt-20` with an 80px header).
|
|
40
41
|
*/
|
|
41
42
|
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
|
|
42
43
|
const L: ElementType = linkComponent ?? "a";
|
|
@@ -45,6 +46,10 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
45
46
|
const listRef = useRef<HTMLUListElement>(null);
|
|
46
47
|
const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
|
|
47
48
|
const [pill, setPill] = useState({ x: 0, y: 0, w: 0, h: 0, ready: false });
|
|
49
|
+
// Stable key of the items: sites pass the array inline, so a new reference
|
|
50
|
+
// on every render. Keyed by reference, the arrival effect below re-ran on
|
|
51
|
+
// every page scroll and kept dragging the page back to the hash.
|
|
52
|
+
const key = items.map((it) => it.id).join("|");
|
|
48
53
|
|
|
49
54
|
// Scroll-spy
|
|
50
55
|
useEffect(() => {
|
|
@@ -63,11 +68,13 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
63
68
|
);
|
|
64
69
|
sections.forEach((s) => io.observe(s));
|
|
65
70
|
return () => io.disconnect();
|
|
66
|
-
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [key]);
|
|
67
73
|
|
|
68
74
|
// Arriving with a #hash via client-side navigation: scrolls to the anchor (on
|
|
69
75
|
// a full load the browser already does it; here it's idempotent). Reapplies a
|
|
70
|
-
// few times to beat the router's late scroll reset.
|
|
76
|
+
// few times to beat the router's late scroll reset. Runs once, on arrival:
|
|
77
|
+
// the hash the person sets by clicking a pill is the browser's job.
|
|
71
78
|
useEffect(() => {
|
|
72
79
|
const hash = window.location.hash.slice(1);
|
|
73
80
|
if (!hash || !items.some((it) => it.id === hash)) return;
|
|
@@ -80,7 +87,8 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
80
87
|
if (++tries >= 4) window.clearInterval(id);
|
|
81
88
|
}, 60);
|
|
82
89
|
return () => window.clearInterval(id);
|
|
83
|
-
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, [key]);
|
|
84
92
|
|
|
85
93
|
const measure = () => {
|
|
86
94
|
const el = linkRefs.current[active];
|
package/components/Button.tsx
CHANGED
|
@@ -15,10 +15,17 @@ const linkBase =
|
|
|
15
15
|
"group inline-flex items-center gap-1.5 font-body font-semibold underline decoration-[1.5px] decoration-current/50 underline-offset-4 transition-[text-decoration-color,text-underline-offset] duration-200 hover:decoration-current hover:underline-offset-[5px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink rounded-[2px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&>svg]:transition-transform [&>svg]:duration-200 hover:[&>svg]:-translate-y-0.5 hover:[&>svg]:translate-x-0.5 motion-reduce:[&>svg]:transform-none";
|
|
16
16
|
|
|
17
17
|
const variants: Record<Exclude<Variant, "link">, string> = {
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// action color
|
|
21
|
-
|
|
18
|
+
// Flat, as the pill buttons of novo.creeta: no drop shadow, no lift; the hover
|
|
19
|
+
// is a change of fill and the tap scales the pill (pillBase). A shadow under a
|
|
20
|
+
// hot action color read as a grey halo (2026-10-29).
|
|
21
|
+
// solid petrol, cream text (e.g. "Falar com a equipe"): hover goes to ink-soft (creeta's teal-escuro)
|
|
22
|
+
solid: "bg-ink text-surface hover:bg-ink-soft",
|
|
23
|
+
// action color (orange/magenta) + on-action text (from the token, varies per site).
|
|
24
|
+
// Same type scale as the other variants: buttons side by side must share a size.
|
|
25
|
+
// On ttcab (white on the vivid red-orange) that is 3.67:1, a conscious exception
|
|
26
|
+
// (Cloudflare and Airbnb ship the same): passes the 3:1 of UI components, not
|
|
27
|
+
// the 4.5:1 of normal text. Keep the accent for ONE large button per block.
|
|
28
|
+
accent: "bg-action text-on-action hover:brightness-[0.92]",
|
|
22
29
|
outline: "border border-line text-ink hover:bg-ink/[0.05]",
|
|
23
30
|
ghost: "text-ink hover:bg-ink/[0.06]",
|
|
24
31
|
};
|
package/components/Byline.tsx
CHANGED
|
@@ -51,11 +51,13 @@ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: "
|
|
|
51
51
|
)}
|
|
52
52
|
</span>
|
|
53
53
|
));
|
|
54
|
+
// min-h-11 keeps the 44px tap target; -my-3 takes those extra 12px out of the
|
|
55
|
+
// flow, so when the link wraps under the names the two lines read as one caption
|
|
54
56
|
const actionEl = action ? (
|
|
55
57
|
<L
|
|
56
58
|
href={action.href}
|
|
57
59
|
{...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
58
|
-
className="inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
|
|
60
|
+
className="-my-3 inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
|
|
59
61
|
>
|
|
60
62
|
{action.label}
|
|
61
63
|
<ArrowUpRight />
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type CareStep = {
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
/** What fits the phone card (two columns). */
|
|
6
|
+
summary: ReactNode;
|
|
7
|
+
/** The full text, from lg. */
|
|
8
|
+
text: ReactNode;
|
|
9
|
+
/** The drawing above the text: a ChatPreview, a photo, a PlanTimeline, a
|
|
10
|
+
* PhotoSequence… It fills the frame (absolute inset-0). Decorative: it
|
|
11
|
+
* repeats what the text says; only a pause button should reach the keyboard. */
|
|
12
|
+
visual: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Labels = {
|
|
16
|
+
/** sr-only before the title: "Step 1: ". The number on screen is decoration. */
|
|
17
|
+
step: (n: number) => string;
|
|
18
|
+
};
|
|
19
|
+
const EN: Labels = { step: (n) => `Step ${n}: ` };
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
steps: CareStep[];
|
|
23
|
+
/** The last tile, on the dark panel: the sentence and the action (a WhatsApp link, a Button). */
|
|
24
|
+
invite: { text: ReactNode; action: ReactNode };
|
|
25
|
+
/** Heading level of the step titles. */
|
|
26
|
+
headingLevel?: "h2" | "h3";
|
|
27
|
+
labels?: Partial<Labels>;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The frame of each drawing: surface colour, corner concentric with the
|
|
32
|
+
* card's (28px minus the 12px of breathing room). */
|
|
33
|
+
const FRAME = "relative h-32 overflow-hidden rounded-2xl bg-surface md:h-52";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How the care works, from the first contact to the treatment — creeta's
|
|
37
|
+
* `Passos`, by tokens. A bento mosaic: each step has its drawing above and
|
|
38
|
+
* its text below; the first takes two columns and the invite closes the
|
|
39
|
+
* sequence:
|
|
40
|
+
*
|
|
41
|
+
* [ 01, wide ] 02 phone: [ 01, wide ]
|
|
42
|
+
* 03 04 invite 02 03
|
|
43
|
+
* 04 invite
|
|
44
|
+
*
|
|
45
|
+
* The invite comes last: it is the conclusion of the steps.
|
|
46
|
+
*/
|
|
47
|
+
export function CareSteps({ steps, invite, headingLevel: Title = "h3", labels, className = "" }: Props) {
|
|
48
|
+
const t = { ...EN, ...labels };
|
|
49
|
+
return (
|
|
50
|
+
<div className={`grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-5 ${className}`}>
|
|
51
|
+
{/* contents: the <li> become tiles of the mosaic and the list stays a list */}
|
|
52
|
+
<ol className="contents">
|
|
53
|
+
{steps.map((s, i) => (
|
|
54
|
+
<li key={i} className={`flex flex-col rounded-[28px] bg-card p-3 ${i === 0 ? "col-span-2 md:flex-row-reverse" : ""}`}>
|
|
55
|
+
{i === 0 ? (
|
|
56
|
+
<div className="relative overflow-hidden rounded-2xl bg-surface md:w-1/2">{s.visual}</div>
|
|
57
|
+
) : (
|
|
58
|
+
<div className={FRAME}>{s.visual}</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
<div className="flex flex-1 flex-col px-2 pb-2 pt-4 md:px-3 md:pb-3 md:pt-5 lg:px-5 lg:pb-5 lg:pt-6">
|
|
62
|
+
{/* The number is visual reinforcement; the order goes as text for the screen reader. */}
|
|
63
|
+
<span aria-hidden="true" className="font-heading text-[1.75rem] font-normal leading-none tracking-[-0.04em] text-ink-soft md:text-[2.5rem]">
|
|
64
|
+
{String(i + 1).padStart(2, "0")}
|
|
65
|
+
</span>
|
|
66
|
+
{/* 16px on the phone: the column has 141px, and at 18px titles broke into three lines */}
|
|
67
|
+
<Title className={`mt-2 font-heading text-base font-medium leading-snug text-ink md:text-xl ${i === 0 ? "md:mt-auto md:pt-6" : "md:mt-3"}`}>
|
|
68
|
+
<span className="sr-only">{t.step(i + 1)}</span>
|
|
69
|
+
{s.title}
|
|
70
|
+
</Title>
|
|
71
|
+
{/* the full text only when the column fits it: up to lg it has less than 180px */}
|
|
72
|
+
<p className="mt-1.5 text-sm leading-relaxed text-ink-body lg:hidden">{s.summary}</p>
|
|
73
|
+
<p className="mt-2 hidden leading-relaxed text-ink-body lg:block">{s.text}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ol>
|
|
78
|
+
|
|
79
|
+
{/* The invite closes the mosaic: it is the last tile. */}
|
|
80
|
+
<div className="flex min-h-[200px] flex-col justify-between gap-6 rounded-[28px] bg-surface-panel p-5 text-surface md:p-6 lg:p-8">
|
|
81
|
+
<p className="font-heading text-[1.375rem] font-medium leading-[1.15] md:text-[2rem] md:leading-[1.12]">{invite.text}</p>
|
|
82
|
+
<div className="self-start">{invite.action}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { Clock } from "lucide-react";
|
|
5
|
+
import { reducedMotion, useOnAppear } from "./useOnAppear";
|
|
6
|
+
import { PauseButton } from "./PauseButton";
|
|
7
|
+
|
|
8
|
+
export type ChatMessage = { from: "person" | "clinic"; text: string };
|
|
9
|
+
|
|
10
|
+
type Labels = {
|
|
11
|
+
/** Completes the pause button's name: "Pause the sample conversation". */
|
|
12
|
+
pause: string;
|
|
13
|
+
};
|
|
14
|
+
const EN: Labels = { pause: "the sample conversation" };
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
/** Small line at the top, with a clock: the opening hours. */
|
|
19
|
+
hours?: string;
|
|
20
|
+
labels?: Partial<Labels>;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** How long the clinic is "typing" before each reply, in ms. */
|
|
25
|
+
const TYPING = 1100;
|
|
26
|
+
/** At the end the conversation rests this long, fades out and restarts. */
|
|
27
|
+
const REST = 3200;
|
|
28
|
+
const EXIT = 350;
|
|
29
|
+
/** Time to read the previous message before the next: grows with its length, 0.9s to 1.9s. */
|
|
30
|
+
export const readingTime = (previous?: ChatMessage) =>
|
|
31
|
+
previous ? Math.min(1900, Math.max(900, 600 + previous.text.length * 28)) : 400;
|
|
32
|
+
|
|
33
|
+
const BUBBLE = "cg-bubble max-w-[15rem] rounded-[20px] px-4 py-3 text-sm leading-snug lg:text-[0.9375rem]";
|
|
34
|
+
const CURVE = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Step 1 of the care flow (creeta's `Conversa`): a sample conversation, bubble
|
|
38
|
+
* by bubble. The person writes, the clinic "types" and answers.
|
|
39
|
+
* - Starts when half of it is on screen and loops: at the end rests 3.2s,
|
|
40
|
+
* fades and restarts. Only runs while on screen.
|
|
41
|
+
* - Works like a real chat: fixed height, new bubbles come in at the bottom,
|
|
42
|
+
* old ones slide up and vanish at the top. Nothing pushes the page.
|
|
43
|
+
* - Moves by itself for more than 5s, so it has a pause button (WCAG 2.2.2).
|
|
44
|
+
* - Without JavaScript, IntersectionObserver or with reduced motion, the whole
|
|
45
|
+
* conversation shows still, without the button.
|
|
46
|
+
* - The chat is decoration (aria-hidden): the step's text already says what
|
|
47
|
+
* matters. The button stays outside it, for the keyboard and screen reader.
|
|
48
|
+
*/
|
|
49
|
+
export function ChatPreview({ messages, hours, labels, className = "" }: Props) {
|
|
50
|
+
const t = { ...EN, ...labels };
|
|
51
|
+
const { ref, state, onScreen } = useOnAppear<HTMLDivElement>();
|
|
52
|
+
const stack = useRef<HTMLDivElement>(null);
|
|
53
|
+
const height = useRef(0);
|
|
54
|
+
const [shown, setShown] = useState(0);
|
|
55
|
+
const [typing, setTyping] = useState(false);
|
|
56
|
+
const [paused, setPaused] = useState(false);
|
|
57
|
+
const [leaving, setLeaving] = useState(false);
|
|
58
|
+
|
|
59
|
+
const visible = state === "whole" ? messages : state === "waiting" ? [] : messages.slice(0, shown);
|
|
60
|
+
|
|
61
|
+
// The clock: one message at a time, and back to the start.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (state !== "playing" || paused || !onScreen) return;
|
|
64
|
+
const wait = readingTime(messages[shown - 1]);
|
|
65
|
+
const timer =
|
|
66
|
+
shown >= messages.length
|
|
67
|
+
? leaving
|
|
68
|
+
? setTimeout(() => {
|
|
69
|
+
setShown(0);
|
|
70
|
+
setLeaving(false);
|
|
71
|
+
}, EXIT)
|
|
72
|
+
: setTimeout(() => setLeaving(true), REST)
|
|
73
|
+
: messages[shown].from === "person"
|
|
74
|
+
? setTimeout(() => setShown((v) => v + 1), wait + 300)
|
|
75
|
+
: typing
|
|
76
|
+
? setTimeout(() => {
|
|
77
|
+
setTyping(false);
|
|
78
|
+
setShown((v) => v + 1);
|
|
79
|
+
}, TYPING)
|
|
80
|
+
: setTimeout(() => setTyping(true), wait);
|
|
81
|
+
return () => clearTimeout(timer);
|
|
82
|
+
}, [state, paused, onScreen, shown, typing, leaving, messages]);
|
|
83
|
+
|
|
84
|
+
// New bubble at the bottom: the stack grows upward at once. To slide the old
|
|
85
|
+
// ones up, it starts offset by the height it gained and returns (transform only).
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
const el = stack.current;
|
|
88
|
+
if (!el) return;
|
|
89
|
+
const gained = el.offsetHeight - height.current;
|
|
90
|
+
height.current = el.offsetHeight;
|
|
91
|
+
if (state !== "playing" || gained <= 0 || reducedMotion() || !el.animate) return;
|
|
92
|
+
el.animate([{ transform: `translateY(${gained}px)` }, { transform: "translateY(0)" }], { duration: 320, easing: CURVE });
|
|
93
|
+
}, [state, shown, typing]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div ref={ref} data-state={state} data-paused={paused ? "" : undefined} className={`cg-chat relative h-full min-h-[17.5rem] md:min-h-[20rem] ${className}`}>
|
|
97
|
+
<div aria-hidden="true">
|
|
98
|
+
{hours && (
|
|
99
|
+
<span className="absolute left-4 top-0 flex h-11 items-center gap-1.5 text-xs font-medium text-ink/80 lg:left-7">
|
|
100
|
+
<Clock size={13} strokeWidth={2} />
|
|
101
|
+
{hours}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
{/* the chat window: what goes past the top fades out */}
|
|
105
|
+
<div className="absolute inset-x-0 bottom-0 top-11 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2rem)]">
|
|
106
|
+
<div
|
|
107
|
+
ref={stack}
|
|
108
|
+
className={`absolute inset-x-0 bottom-0 flex flex-col gap-2.5 px-4 pb-4 transition-opacity duration-300 ease-out motion-reduce:transition-none lg:px-7 lg:pb-7 ${leaving ? "opacity-0" : ""}`}
|
|
109
|
+
>
|
|
110
|
+
{visible.map((m, i) => (
|
|
111
|
+
<span
|
|
112
|
+
key={i}
|
|
113
|
+
className={
|
|
114
|
+
m.from === "person"
|
|
115
|
+
? `${BUBBLE} origin-bottom-right self-end rounded-br-md bg-ink text-surface`
|
|
116
|
+
: `${BUBBLE} origin-bottom-left self-start rounded-bl-md bg-card text-ink`
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{m.text}
|
|
120
|
+
</span>
|
|
121
|
+
))}
|
|
122
|
+
{typing && (
|
|
123
|
+
<span className="cg-bubble cg-typing flex origin-bottom-left gap-1 self-start rounded-[20px] rounded-bl-md bg-card px-4 py-3.5">
|
|
124
|
+
{[0, 1, 2].map((n) => (
|
|
125
|
+
<i key={n} style={{ "--n": n } as CSSProperties} className="h-1.5 w-1.5 rounded-full bg-ink-soft" />
|
|
126
|
+
))}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{state !== "whole" && (
|
|
134
|
+
<PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} what={t.pause} className="right-0 top-0 lg:right-2" />
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Byline, type Person } from "./Byline";
|
|
3
|
+
import type { PostCover } from "./PostCard";
|
|
4
|
+
|
|
5
|
+
export type FeaturedPost = {
|
|
6
|
+
cover: PostCover;
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
/** The post's own page. */
|
|
9
|
+
href: string;
|
|
10
|
+
text: ReactNode;
|
|
11
|
+
/** Who said it (name, portrait): the text is their words, only cut. */
|
|
12
|
+
speaker: Person;
|
|
13
|
+
/** Link to the video the text came from ("Watch the video ↗"). */
|
|
14
|
+
video?: { href: string; label?: ReactNode; external?: boolean };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
posts: FeaturedPost[];
|
|
19
|
+
headingLevel?: "h2" | "h3";
|
|
20
|
+
/** Default label of the video link. */
|
|
21
|
+
watchLabel?: ReactNode;
|
|
22
|
+
newTabLabel?: string;
|
|
23
|
+
linkComponent?: ElementType;
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Featured posts of the Cuidado Global family — creeta's `Temas` ("in the
|
|
29
|
+
* words of who treats you"): what the team explains in the videos, as text.
|
|
30
|
+
* Each card: the cover above, the title and the text, and at the foot who
|
|
31
|
+
* said it (with the portrait) and the link to the video, because the text is
|
|
32
|
+
* their speech, only with cuts. Bento: the first post takes two columns with
|
|
33
|
+
* the cover beside the text; if the last would sit alone in its row, it too.
|
|
34
|
+
*/
|
|
35
|
+
export function FeaturedPosts({ posts, headingLevel: Title = "h3", watchLabel = "Watch the video", newTabLabel, linkComponent, className = "" }: Props) {
|
|
36
|
+
const L: ElementType = linkComponent ?? "a";
|
|
37
|
+
return (
|
|
38
|
+
<ul className={`grid list-none gap-3 md:grid-cols-2 md:gap-5 ${className}`}>
|
|
39
|
+
{posts.map((p, i) => {
|
|
40
|
+
const wide = i === 0 || (i === posts.length - 1 && (posts.length - 1) % 2 === 1);
|
|
41
|
+
return (
|
|
42
|
+
<li key={i} className={`flex flex-col overflow-hidden rounded-[28px] bg-card ${wide ? "md:col-span-2 md:grid md:grid-cols-5" : ""}`}>
|
|
43
|
+
{/* cover: decoration (4:3); the title says the subject */}
|
|
44
|
+
<div className={`relative overflow-hidden ${wide ? "aspect-[4/3] md:col-span-2 md:aspect-auto md:min-h-[320px]" : "aspect-[4/3]"}`}>
|
|
45
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
46
|
+
<img
|
|
47
|
+
src={p.cover.src}
|
|
48
|
+
srcSet={p.cover.srcSet}
|
|
49
|
+
sizes={p.cover.sizes ?? (wide ? "(min-width: 1152px) 440px, (min-width: 768px) 40vw, 92vw" : "(min-width: 1152px) 540px, (min-width: 768px) 46vw, 92vw")}
|
|
50
|
+
alt=""
|
|
51
|
+
loading="lazy"
|
|
52
|
+
decoding="async"
|
|
53
|
+
className="absolute inset-0 h-full w-full object-cover object-[50%_30%]"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className={`flex flex-1 flex-col p-6 md:p-8 ${wide ? "md:col-span-3 md:justify-center" : ""}`}>
|
|
58
|
+
<Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
|
|
59
|
+
<L
|
|
60
|
+
href={p.href}
|
|
61
|
+
className="underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink motion-reduce:transition-none"
|
|
62
|
+
>
|
|
63
|
+
{p.title}
|
|
64
|
+
</L>
|
|
65
|
+
</Title>
|
|
66
|
+
<p className="mt-3 leading-relaxed text-ink-body">{p.text}</p>
|
|
67
|
+
<Byline
|
|
68
|
+
className="mt-auto pt-6"
|
|
69
|
+
people={[p.speaker]}
|
|
70
|
+
action={p.video ? { label: p.video.label ?? watchLabel, href: p.video.href, external: p.video.external } : undefined}
|
|
71
|
+
newTabLabel={newTabLabel}
|
|
72
|
+
linkComponent={linkComponent}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</ul>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/components/Footer.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import { List } from "./List";
|
|
|
6
6
|
type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
7
7
|
|
|
8
8
|
export type FooterLink = { href: string; label: ReactNode };
|
|
9
|
+
/** A titled column of links, for footers with many pages ("Foundations", "Cards…"). */
|
|
10
|
+
export type FooterLinkGroup = { title: ReactNode; links: FooterLink[] };
|
|
9
11
|
export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
|
|
10
12
|
export type FooterLanguage = Language;
|
|
11
13
|
|
|
@@ -29,8 +31,11 @@ type Props = {
|
|
|
29
31
|
/** The brand sentence, shown in quotes under the logo. */
|
|
30
32
|
quote?: ReactNode;
|
|
31
33
|
/** "Pages": title on the first line, links in two columns below. Optional:
|
|
32
|
-
* a footer with no internal links drops the column (two-column grid).
|
|
33
|
-
|
|
34
|
+
* a footer with no internal links drops the column (two-column grid).
|
|
35
|
+
* Many links (a dozen or more): pass `groups` instead of `links` — each
|
|
36
|
+
* group is a column with a small heading, up to four side by side, so the
|
|
37
|
+
* list gets wider instead of taller (the 44px rows stay). */
|
|
38
|
+
pages?: { title: ReactNode; links?: FooterLink[]; groups?: FooterLinkGroup[] };
|
|
34
39
|
/** "Where we are": address lines, opening hours, and the directions links
|
|
35
40
|
* ("Directions ↗ · Waze ↗", external). */
|
|
36
41
|
where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
|
|
@@ -59,6 +64,14 @@ const ArrowUpRight = () => (
|
|
|
59
64
|
);
|
|
60
65
|
|
|
61
66
|
const title = "font-heading text-lg font-medium text-ink";
|
|
67
|
+
const groupTitle = "text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft";
|
|
68
|
+
// whole class names: as many columns as groups, up to four
|
|
69
|
+
const groupCols: Record<number, string> = {
|
|
70
|
+
1: "grid-cols-1",
|
|
71
|
+
2: "grid-cols-2",
|
|
72
|
+
3: "grid-cols-2 lg:grid-cols-3",
|
|
73
|
+
4: "grid-cols-2 lg:grid-cols-4",
|
|
74
|
+
};
|
|
62
75
|
const link =
|
|
63
76
|
"flex min-h-11 items-center text-ink/80 underline decoration-transparent underline-offset-4 transition-[color,text-decoration-color] duration-150 hover:text-ink hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
64
77
|
const strongLink =
|
|
@@ -100,12 +113,19 @@ export function Footer({
|
|
|
100
113
|
// padding leaves room for the floating bar.
|
|
101
114
|
const outer = navHiddenFrom === "md" ? "mt-16 pb-28 md:mt-24 md:pb-14" : "mt-16 pb-28 lg:mt-24 lg:pb-14";
|
|
102
115
|
const noticeId = notice?.id ?? "footer-notice";
|
|
116
|
+
const groups = pages?.groups?.length ? pages.groups : undefined;
|
|
117
|
+
// grouped pages need the room: the pages column grows and the brand column shrinks
|
|
118
|
+
const columns = !pages
|
|
119
|
+
? "md:grid-cols-[minmax(0,1fr)_auto]"
|
|
120
|
+
: groups
|
|
121
|
+
? "md:grid-cols-[minmax(0,3.5fr)_minmax(0,6.5fr)_minmax(0,3fr)]"
|
|
122
|
+
: "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]";
|
|
103
123
|
|
|
104
124
|
return (
|
|
105
125
|
<footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
|
|
106
126
|
<div className="mx-auto max-w-[1600px] px-4 md:px-6">
|
|
107
127
|
{/* without the pages column, "where" keeps to the right edge (auto width) */}
|
|
108
|
-
<div className={`grid gap-x-10 gap-y-10 ${
|
|
128
|
+
<div className={`grid gap-x-10 gap-y-10 ${columns}`}>
|
|
109
129
|
<div>
|
|
110
130
|
{logo}
|
|
111
131
|
{brandName && <p className="sr-only">{brandName}</p>}
|
|
@@ -117,13 +137,30 @@ export function Footer({
|
|
|
117
137
|
{pages && (
|
|
118
138
|
<nav aria-label={t.pagesNav}>
|
|
119
139
|
<h2 className={title}>{pages.title}</h2>
|
|
120
|
-
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
{groups ? (
|
|
141
|
+
<div className={`mt-5 grid gap-x-6 gap-y-8 ${groupCols[Math.min(groups.length, 4)]}`}>
|
|
142
|
+
{groups.map((g, gi) => (
|
|
143
|
+
<div key={gi}>
|
|
144
|
+
<h3 className={groupTitle}>{g.title}</h3>
|
|
145
|
+
<div className="mt-2">
|
|
146
|
+
{g.links.map((p, i) => (
|
|
147
|
+
<L key={`${i}-${p.href}`} href={p.href} className={link}>
|
|
148
|
+
{p.label}
|
|
149
|
+
</L>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
|
|
157
|
+
{(pages.links ?? []).map((p, i) => (
|
|
158
|
+
<L key={`${i}-${p.href}`} href={p.href} className={link}>
|
|
159
|
+
{p.label}
|
|
160
|
+
</L>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
127
164
|
</nav>
|
|
128
165
|
)}
|
|
129
166
|
|
package/components/Header.tsx
CHANGED
|
@@ -112,7 +112,9 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
|
|
|
112
112
|
<L
|
|
113
113
|
href={c.href}
|
|
114
114
|
onClick={() => setOpen(false)}
|
|
115
|
-
|
|
115
|
+
// with a description the icon sits with the label's first line (creeta);
|
|
116
|
+
// label only: centred on the bubble, and a two-line label stays tight
|
|
117
|
+
className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
|
|
116
118
|
>
|
|
117
119
|
{c.icon && (
|
|
118
120
|
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-ink/[0.08] text-ink">
|
|
@@ -120,7 +122,7 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
|
|
|
120
122
|
</span>
|
|
121
123
|
)}
|
|
122
124
|
<span>
|
|
123
|
-
<span className="block font-heading text-[0.95rem] font-semibold text-ink">
|
|
125
|
+
<span className="block font-heading text-[0.95rem] leading-snug font-semibold text-ink">
|
|
124
126
|
{c.label}
|
|
125
127
|
</span>
|
|
126
128
|
{c.description && (
|