@breathe-ds/ui 0.2.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 +118 -13
- package/components/Accordion.tsx +6 -4
- package/components/AnchorNav.tsx +12 -4
- package/components/Avatar.tsx +37 -0
- package/components/BottomNav.tsx +1 -1
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +0 -13
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +105 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +50 -30
- package/components/Header.tsx +4 -2
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +1 -1
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +0 -18
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PersonChip.tsx +49 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +446 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rail.tsx +94 -0
- package/components/Rows.tsx +137 -0
- package/components/StoryPanel.tsx +107 -0
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +34 -2
- package/theme.css +206 -0
- package/tokens/creeta.css +7 -2
- package/tokens/neutral.css +12 -4
- package/tokens/ttcab.css +14 -5
package/README.md
CHANGED
|
@@ -52,18 +52,34 @@ For a site without a ready-made token file, define the roles on `:root` itself:
|
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
`theme.css` also sets the family's base behaviour: smooth scrolling for anchors
|
|
56
|
+
(`html { scroll-behavior: smooth }`, switched off under `prefers-reduced-motion`)
|
|
57
|
+
and `scroll-padding` (6rem top, 7rem bottom) so keyboard focus never lands
|
|
58
|
+
hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
|
|
59
|
+
|
|
55
60
|
## Token roles
|
|
56
61
|
|
|
57
62
|
| Token (Tailwind) | Role | `--cg-*` |
|
|
58
63
|
|---|---|---|
|
|
59
64
|
| `surface` | base background | `--cg-surface` |
|
|
60
65
|
| `surface-panel` | dark panel (footer, final CTA) | `--cg-surface-panel` |
|
|
66
|
+
| `card` | raised surface: cards, pills, menus over the page (white on all three palettes) | `--cg-card` |
|
|
61
67
|
| `ink` | primary text / headings | `--cg-ink` |
|
|
62
68
|
| `ink-soft` | secondary text | `--cg-ink-soft` |
|
|
63
69
|
| `ink-body` | reading body text | `--cg-ink-body` |
|
|
64
70
|
| `action` | CTA / highlight — **never plain text** | `--cg-action` |
|
|
65
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` |
|
|
66
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.
|
|
67
83
|
| `font-heading` / `font-body` | typography | `--cg-font-*` |
|
|
68
84
|
| `radius-xl/2xl/pill` · `ease-cg` | radius and motion curve | fixed |
|
|
69
85
|
|
|
@@ -76,10 +92,11 @@ Values from the standard WCAG formula (the same ones `preview/` computes live):
|
|
|
76
92
|
| surface `#fbf5e9` / ink `#144753` | 9.41:1 | AAA |
|
|
77
93
|
| surface `#fbf5e9` / ink-soft `#54707a` | 4.86:1 | AA |
|
|
78
94
|
| surface `#fbf5e9` / ink-body `#1e4f5b` | 8.32:1 | AAA |
|
|
79
|
-
| 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 |
|
|
80
97
|
| surface-panel `#144753` / cream text | 9.41:1 | AAA |
|
|
81
98
|
|
|
82
|
-
`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`.
|
|
83
100
|
|
|
84
101
|
## Family typography
|
|
85
102
|
|
|
@@ -147,6 +164,17 @@ edited by hand: it is the one from each brand's manual.
|
|
|
147
164
|
novo.creeta: symbol, then name, then tagline; with reduced motion it becomes
|
|
148
165
|
a short fade.
|
|
149
166
|
|
|
167
|
+
## Typography
|
|
168
|
+
|
|
169
|
+
The family's scale, from novo.creeta, as components: `Heading` (sizes
|
|
170
|
+
`display` 2.5rem→6xl · `hero` · `section` 1.75rem→4xl · `card` · `small`;
|
|
171
|
+
Quicksand medium in `ink`) with an optional `accent` — the second sentence of
|
|
172
|
+
the same heading on its own line, one tone below in `ink-soft` — and
|
|
173
|
+
`SectionHeading` (eyebrow/number line, title + accent, lead). `Text` for
|
|
174
|
+
paragraphs (`lead`, `body`, `small`, `eyebrow`, Figtree) and `Prose` for a run
|
|
175
|
+
of paragraphs at reading width. Two tones, one element: the hierarchy comes
|
|
176
|
+
from color, not from extra markup.
|
|
177
|
+
|
|
150
178
|
## Navigation
|
|
151
179
|
|
|
152
180
|
- **Header**: brand (the app passes the link to the home page) · items · item with dropdown
|
|
@@ -168,9 +196,90 @@ a short fade.
|
|
|
168
196
|
the code, opens the three languages; header) or `list` (plain links; the Footer
|
|
169
197
|
uses it). Same page in the other language, each language written in itself,
|
|
170
198
|
current marked by ✓ and `aria-current`.
|
|
199
|
+
- **List**: the family's list with a marker in front of each item (novo.creeta):
|
|
200
|
+
`marker="check"` (24px bubble, "what we assess / what we treat") or
|
|
201
|
+
`marker="arrow"` (corner arrow, one sentence per item, as in the Footer
|
|
202
|
+
notice). An item with `href` is a link: semibold, underline, outbound arrow.
|
|
203
|
+
- **Timeline**: the "Como cuidamos" of novo.creeta — ordered steps, each with a
|
|
204
|
+
44px icon bubble, a title and a paragraph; a thin line links the drawings. A
|
|
205
|
+
step may end with a link (strong link + outbound arrow).
|
|
206
|
+
- **RowList** / **CollapseRow** / **LinkRow**: the list of equal rows at the foot
|
|
207
|
+
of a card (the Trajectory / Guidance / Instagram block of the ProfessionalCard),
|
|
208
|
+
generic. 48px rows with hairlines; a CollapseRow is a native `<details>` with a
|
|
209
|
+
text or a list of links (`links`, count beside the label); a LinkRow goes
|
|
210
|
+
somewhere (↗ another page, ↓ anchor, `external` for a new tab, `icon` to
|
|
211
|
+
replace the arrow). The ProfessionalCard is built on them.
|
|
212
|
+
- **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
|
|
213
|
+
novo.creeta — portrait (plain, living clip on hover, or with the "Presentation"
|
|
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).
|
|
225
|
+
- **Byline** / **PersonChip** / **Avatar**: who signs or explains content
|
|
226
|
+
(novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
|
|
227
|
+
+ "Watch the video ↗"; the person chip (portrait, name, role · registration,
|
|
228
|
+
arrow bubble when it links to the person's card). No photo → initials bubble.
|
|
229
|
+
- **Breadcrumb**: the article trail "← Section › Parent" (current page not listed).
|
|
230
|
+
- **FactStrip**: the three facts under the hero of novo.creeta — icon bubble +
|
|
231
|
+
two lines of equal weight per fact, thin dividers between columns (rows on
|
|
232
|
+
phones); the separator between the lines is read by screen readers only.
|
|
233
|
+
- **InfoCard**: the white info cards of the clinic page (icon bubble, title,
|
|
234
|
+
lines/address, note, outbound links "Directions ↗ · Waze ↗").
|
|
235
|
+
- **MediaCard**: photo or muted clip behind a title, badge top-left, arrow button
|
|
236
|
+
top-right, whole card a link; Ken Burns zoom on hover (mouse only), clip plays
|
|
237
|
+
while visible or on hover; pause hooks exported for a page-level pause button.
|
|
238
|
+
The portrait variant ("Who created the method"): `eyebrow` + title + `linkLabel`
|
|
239
|
+
("See the team ↗") with `arrowButton={false}`.
|
|
240
|
+
- **VideoStoryCard**: the vertical Instagram-style video card (poster, play
|
|
241
|
+
button, duration badge; gradient grows and the lead appears on hover/focus;
|
|
242
|
+
tap plays with sound in place; optional muted hover preview). When the clip
|
|
243
|
+
ends the card returns to the poster with a "Watched" badge (check + word,
|
|
244
|
+
top-right): `watched` sets it from the site, `onWatched` fires once at the end,
|
|
245
|
+
`remember` keeps the set of watched clips in localStorage (by `src`). Exports
|
|
246
|
+
the page-level pause contract (`CARD_VIDEOS_PAUSE_EVENT` / `CARD_VIDEOS_PAUSED_ATTR`).
|
|
247
|
+
- **TestimonialCard**: clip with a play button on the left, quote + author +
|
|
248
|
+
outbound link on the right ("Famílias contam").
|
|
249
|
+
- **MapCard**: the Mapa of novo.creeta — a still preview fading into the card,
|
|
250
|
+
the address, "View the map" and the outbound "Directions" link; Google is only
|
|
251
|
+
called after the tap (no consent banner, lighter page). Open, the map fills
|
|
252
|
+
the card with the same close bar as the hero video.
|
|
253
|
+
- **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
|
|
254
|
+
card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
|
|
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.
|
|
171
278
|
- **Footer**: the site-footer of novo.creeta, by tokens — full width like the
|
|
172
279
|
Header, with the signed brand + sentence, "Pages" (title on the first line, links in two
|
|
173
|
-
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
|
|
174
283
|
notice band on `surface`, the legal line with the **CNPJ (always required in
|
|
175
284
|
the family)** plus the contact chips, and the languages as plain links.
|
|
176
285
|
`navHiddenFrom` keeps room at the bottom for the BottomNav.
|
|
@@ -182,7 +291,7 @@ a short fade.
|
|
|
182
291
|
## Roadmap
|
|
183
292
|
|
|
184
293
|
- [x] v0.1 — tokens (preset + ttcab/creeta palettes) + preview
|
|
185
|
-
- [x] Accordion (1st component; injectable-deps pattern)
|
|
294
|
+
- [x] Accordion (1st component; injectable-deps pattern) — the Perguntas of novo.creeta: white card (`bg-card`), 22px radius, no border
|
|
186
295
|
- [x] Family typography (Quicksand + Figtree, free fonts only)
|
|
187
296
|
- [ ] **novo.creeta scales** (full reference, approved): **typography** (sizes/weights/line-height)
|
|
188
297
|
**+ spacing/rhythm** (section padding, gaps) **+ default border-radius** → become DS tokens
|
|
@@ -190,19 +299,15 @@ a short fade.
|
|
|
190
299
|
import/inject it (without each site depending on lucide directly)
|
|
191
300
|
- [ ] **MediaCard** (video/image behind + icon badge + title + lead + arrow;
|
|
192
301
|
**horizontal carousel on mobile** variant with progress bar + pause) — approved
|
|
193
|
-
- [
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
- [ ] **VideoStoryCard** (short vertical video) — approved. Play + duration badge +
|
|
197
|
-
title + lead + **"Read the transcript"** (collapsible, reuses the Accordion). Pattern of the Videos page.
|
|
198
|
-
_Note: the card can still improve in a future update (fine to leave for later)._
|
|
199
|
-
- [ ] **ProfessionalCard** (team) — approved. Two versions: **small** (photo + name, in a row)
|
|
302
|
+
- [x] **TestimonialCard** — from novo.creeta (10/29): clip + play on the left, quote/author/link on the right. The "video hero" and "video beside" variants from ttcab are still open
|
|
303
|
+
- [x] **VideoStoryCard** — from novo.creeta (10/29): vertical card, play + duration badge, lead on hover/focus, plays with sound in place; "Read the transcript" goes in `children` (Accordion)
|
|
304
|
+
- [x] **ProfessionalCard** (team) — from novo.creeta (10/29): small = PersonChip/Byline, big = the card with living portrait, presentation modal + captions. Original note: Two versions: **small** (photo + name, in a row)
|
|
200
305
|
and **big** (photo + name + role + CREFITO + "Trajetória"/"Orientações" accordions).
|
|
201
306
|
**Video** variant: "Apresentação" badge, hover plays the video, and a **modal** (video + bio).
|
|
202
307
|
**Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
|
|
203
|
-
- [x] **PillList** (
|
|
308
|
+
- [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
|
|
204
309
|
- [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
|
|
205
|
-
- [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**
|
|
206
311
|
- [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
|
|
207
312
|
- [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
|
|
208
313
|
**already exists in ttcab** → extract.
|
package/components/Accordion.tsx
CHANGED
|
@@ -51,7 +51,8 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
|
|
|
51
51
|
return (
|
|
52
52
|
<div
|
|
53
53
|
key={it.id}
|
|
54
|
-
|
|
54
|
+
// the Perguntas of novo.creeta: a white card (bg-card), 22px radius, no border — it reads by tone over the page
|
|
55
|
+
className="overflow-hidden rounded-[22px] bg-card px-5 md:px-7"
|
|
55
56
|
>
|
|
56
57
|
<h3 className="m-0">
|
|
57
58
|
<button
|
|
@@ -60,13 +61,13 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
|
|
|
60
61
|
aria-expanded={isOpen}
|
|
61
62
|
aria-controls={panelId}
|
|
62
63
|
onClick={() => toggle(it.id)}
|
|
63
|
-
className="flex w-full items-center justify-between gap-4
|
|
64
|
+
className="flex min-h-14 w-full items-center justify-between gap-4 py-1 text-left font-heading text-lg font-medium text-ink transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
64
65
|
>
|
|
65
66
|
<span>{it.title}</span>
|
|
66
67
|
<svg
|
|
67
68
|
aria-hidden="true"
|
|
68
69
|
viewBox="0 0 24 24"
|
|
69
|
-
className={`h-5 w-5 flex-none text-ink
|
|
70
|
+
className={`h-5 w-5 flex-none text-ink transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
|
70
71
|
isOpen ? "rotate-180" : ""
|
|
71
72
|
}`}
|
|
72
73
|
fill="none"
|
|
@@ -91,7 +92,8 @@ export function Accordion({ items, defaultOpenId, single = true }: Props) {
|
|
|
91
92
|
}`}
|
|
92
93
|
>
|
|
93
94
|
<div className="min-h-0 overflow-hidden">
|
|
94
|
-
|
|
95
|
+
{/* full-width item, reading-width body (max-w-2xl), as the Perguntas of novo.creeta */}
|
|
96
|
+
<div className="max-w-2xl pb-5 leading-relaxed text-ink/80">
|
|
95
97
|
{it.content}
|
|
96
98
|
</div>
|
|
97
99
|
</div>
|
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];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Person's name: the initials fallback and nothing else (the image is decorative). */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Photo src. Without it, the initials in a tinted bubble (as the team cards of novo.creeta). */
|
|
7
|
+
photo?: string;
|
|
8
|
+
/** Your own image element (e.g. next/image), replaces `photo`. */
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
/** Side in px. */
|
|
11
|
+
size?: number;
|
|
12
|
+
/** White ring, for stacked groups. */
|
|
13
|
+
ring?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const initials = (name: string) =>
|
|
18
|
+
name
|
|
19
|
+
.trim()
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter((w) => w.length > 2 || w === name)
|
|
22
|
+
.slice(0, 2)
|
|
23
|
+
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
24
|
+
.join("");
|
|
25
|
+
|
|
26
|
+
/** Round portrait, decorative (the name is written next to it). */
|
|
27
|
+
export function Avatar({ name, photo, children, size = 40, ring = false, className = "" }: Props) {
|
|
28
|
+
const style = { width: size, height: size, fontSize: Math.round(size * 0.36) };
|
|
29
|
+
const base = `inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full ${ring ? "ring-2 ring-card" : ""} ${className}`;
|
|
30
|
+
if (children) return <span aria-hidden="true" className={base} style={style}>{children}</span>;
|
|
31
|
+
if (photo) return <img src={photo} alt="" loading="lazy" decoding="async" className={`${base} object-cover`} style={style} />;
|
|
32
|
+
return (
|
|
33
|
+
<span aria-hidden="true" className={`${base} bg-ink/[0.12] font-heading font-bold text-ink`} style={style}>
|
|
34
|
+
{initials(name)}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
package/components/BottomNav.tsx
CHANGED
|
@@ -282,7 +282,7 @@ const CSS = `
|
|
|
282
282
|
.cg-more-panel{
|
|
283
283
|
position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
|
|
284
284
|
width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
|
|
285
|
-
background
|
|
285
|
+
background:var(--color-card); color:var(--color-ink);
|
|
286
286
|
box-shadow:0 20px 48px -16px rgb(0 0 0 / .35), 0 1px 2px rgb(0 0 0 / .1);
|
|
287
287
|
opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
|
|
288
288
|
transition:opacity 180ms cubic-bezier(.23,1,.32,1), transform 180ms cubic-bezier(.23,1,.32,1), overlay 180ms allow-discrete, display 180ms allow-discrete;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type Crumb = { label: ReactNode; href: string };
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** From the section the page belongs to down to the parent of the current page. */
|
|
7
|
+
items: Crumb[];
|
|
8
|
+
linkComponent?: ElementType;
|
|
9
|
+
/** aria-label of the nav. */
|
|
10
|
+
label?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// trail link: no resting underline (two underlines side by side weighed too much);
|
|
15
|
+
// it shows on hover and on focus
|
|
16
|
+
const link =
|
|
17
|
+
"inline-flex min-h-11 items-center text-ink underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink focus-visible:decoration-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Breadcrumb — the trail of a novo.creeta article: "← Section › Parent", so
|
|
21
|
+
* the text starts right away. The first crumb carries the back arrow; the
|
|
22
|
+
* current page is not listed (its title follows).
|
|
23
|
+
*/
|
|
24
|
+
export function Breadcrumb({ items, linkComponent, label = "Trail", className = "" }: Props) {
|
|
25
|
+
const L: ElementType = linkComponent ?? "a";
|
|
26
|
+
return (
|
|
27
|
+
<nav aria-label={label} className={className}>
|
|
28
|
+
<ol className="m-0 flex list-none flex-wrap items-center gap-x-2 p-0 text-sm font-semibold text-ink/80">
|
|
29
|
+
{items.map((c, i) => (
|
|
30
|
+
<li key={`${i}-${c.href}`} className="flex items-center gap-x-2">
|
|
31
|
+
{i > 0 && (
|
|
32
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} className="text-ink/50" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6" /></svg>
|
|
33
|
+
)}
|
|
34
|
+
<L href={c.href} className={`${link} ${i === 0 ? "gap-1.5" : ""}`}>
|
|
35
|
+
{i === 0 && (
|
|
36
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7M19 12H5" /></svg>
|
|
37
|
+
)}
|
|
38
|
+
{c.label}
|
|
39
|
+
</L>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ol>
|
|
43
|
+
</nav>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -27,19 +27,6 @@ export function BreatheMark({
|
|
|
27
27
|
}: Props) {
|
|
28
28
|
return (
|
|
29
29
|
<span className={className} style={{ display: "inline-flex", lineHeight: 0 }}>
|
|
30
|
-
<style>{`
|
|
31
|
-
@keyframes breathe-mark-inhale {
|
|
32
|
-
0%, 100% { transform: scale(0.93); }
|
|
33
|
-
50% { transform: scale(1.05); }
|
|
34
|
-
}
|
|
35
|
-
.breathe-mark__g {
|
|
36
|
-
transform-box: fill-box;
|
|
37
|
-
transform-origin: center;
|
|
38
|
-
animation: breathe-mark-inhale var(--breathe-dur, 6s) cubic-bezier(0.37, 0, 0.63, 1) infinite;
|
|
39
|
-
}
|
|
40
|
-
.breathe-mark--static .breathe-mark__g { animation: none; }
|
|
41
|
-
@media (prefers-reduced-motion: reduce) { .breathe-mark__g { animation: none; } }
|
|
42
|
-
`}</style>
|
|
43
30
|
<svg
|
|
44
31
|
width={size}
|
|
45
32
|
height={size}
|
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
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Avatar } from "./Avatar";
|
|
3
|
+
|
|
4
|
+
export type Person = { name: string; href?: string; photo?: string; avatar?: ReactNode };
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
people: Person[];
|
|
8
|
+
/** Small line above the names ("Who explains"). With it, the avatars stack
|
|
9
|
+
* and the names go on their own line; without it, one inline line:
|
|
10
|
+
* avatar · name · action. */
|
|
11
|
+
eyebrow?: ReactNode;
|
|
12
|
+
/** Link after the name(s) ("Watch the video ↗"). */
|
|
13
|
+
action?: { label: ReactNode; href: string; external?: boolean };
|
|
14
|
+
/** Joiners between names: ", " and " and ". */
|
|
15
|
+
joiners?: { sep: string; last: string };
|
|
16
|
+
/** Wrap the names in <cite> (a testimonial's author). */
|
|
17
|
+
cite?: boolean;
|
|
18
|
+
/** sr-only after an external action link. */
|
|
19
|
+
newTabLabel?: string;
|
|
20
|
+
linkComponent?: ElementType;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ArrowUpRight = () => (
|
|
25
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const nameLink =
|
|
29
|
+
"underline decoration-ink/30 underline-offset-4 transition-colors duration-150 hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Byline — who signs or explains a piece of content, from novo.creeta:
|
|
33
|
+
* - group ("Quem explica" under the timeline): stacked portraits, an eyebrow
|
|
34
|
+
* and the names, each one a link to the person's card;
|
|
35
|
+
* - inline (under a topic card): portrait, name and "Watch the video ↗".
|
|
36
|
+
* Portraits are decorative: the names carry the information.
|
|
37
|
+
*/
|
|
38
|
+
export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
|
|
39
|
+
const L: ElementType = linkComponent ?? "a";
|
|
40
|
+
// portraits stack with a ring (as on the site); initials would lose their letters, so they sit side by side
|
|
41
|
+
const stack = people.length > 1 && people.every((p) => p.photo || p.avatar);
|
|
42
|
+
const row = `flex shrink-0 ${stack ? "-space-x-2" : "gap-1.5"}`;
|
|
43
|
+
const Names: ElementType = cite ? "cite" : "span";
|
|
44
|
+
const names = people.map((p, i) => (
|
|
45
|
+
<span key={p.name}>
|
|
46
|
+
{i > 0 && (i === people.length - 1 ? joiners.last : joiners.sep)}
|
|
47
|
+
{p.href ? (
|
|
48
|
+
<L href={p.href} className={nameLink}>{p.name}</L>
|
|
49
|
+
) : (
|
|
50
|
+
p.name
|
|
51
|
+
)}
|
|
52
|
+
</span>
|
|
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
|
|
56
|
+
const actionEl = action ? (
|
|
57
|
+
<L
|
|
58
|
+
href={action.href}
|
|
59
|
+
{...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
60
|
+
className="-my-3 inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
|
|
61
|
+
>
|
|
62
|
+
{action.label}
|
|
63
|
+
<ArrowUpRight />
|
|
64
|
+
{action.external && <span className="sr-only"> {newTabLabel}</span>}
|
|
65
|
+
</L>
|
|
66
|
+
) : null;
|
|
67
|
+
|
|
68
|
+
if (eyebrow) {
|
|
69
|
+
return (
|
|
70
|
+
<div className={`flex items-center gap-3 ${className}`}>
|
|
71
|
+
<span className={row}>
|
|
72
|
+
{people.map((p) => (
|
|
73
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
|
|
74
|
+
))}
|
|
75
|
+
</span>
|
|
76
|
+
<div className="leading-snug">
|
|
77
|
+
<p className="m-0 text-sm text-ink/80">{eyebrow}</p>
|
|
78
|
+
<p className="m-0 mt-0.5 font-semibold text-ink">
|
|
79
|
+
<Names className="not-italic">{names}</Names>
|
|
80
|
+
{actionEl && <> · {actionEl}</>}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<p className={`m-0 flex items-center gap-3 text-sm text-ink/80 ${className}`}>
|
|
89
|
+
{people.length === 1 ? (
|
|
90
|
+
<Avatar name={people[0].name} photo={people[0].photo} size={40}>{people[0].avatar}</Avatar>
|
|
91
|
+
) : (
|
|
92
|
+
<span className={row}>
|
|
93
|
+
{people.map((p) => (
|
|
94
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
|
|
95
|
+
))}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
<span className="flex flex-wrap items-center gap-x-2">
|
|
99
|
+
<Names className="not-italic font-semibold text-ink">{names}</Names>
|
|
100
|
+
{actionEl}
|
|
101
|
+
</span>
|
|
102
|
+
</p>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The line being spoken right now, read from the video's WebVTT <track>
|
|
7
|
+
* (novo.creeta's `useLegenda`, components/legenda.tsx).
|
|
8
|
+
*
|
|
9
|
+
* The captions are not drawn over the picture: many of the site's videos
|
|
10
|
+
* already carry burned-in Portuguese captions, in positions that vary, and one
|
|
11
|
+
* on top of the other is unreadable. The track is the browser's (a hidden
|
|
12
|
+
* <track> it loads and syncs itself) and the text comes out beside/under the
|
|
13
|
+
* video, in `CaptionBand`.
|
|
14
|
+
*
|
|
15
|
+
* Usage: `const { line, attach } = useCaption()` and `<track ref={attach} … />`.
|
|
16
|
+
*/
|
|
17
|
+
export function useCaption() {
|
|
18
|
+
const [line, setLine] = useState("");
|
|
19
|
+
|
|
20
|
+
const attach = useCallback((el: HTMLTrackElement | null) => {
|
|
21
|
+
const track = el?.track;
|
|
22
|
+
if (!track) return;
|
|
23
|
+
// 'hidden': the browser loads the file and fires the events, but does not draw
|
|
24
|
+
track.mode = "hidden";
|
|
25
|
+
track.addEventListener("cuechange", () => {
|
|
26
|
+
const cue = track.activeCues?.[0] as VTTCue | undefined;
|
|
27
|
+
setLine(cue ? cue.text : "");
|
|
28
|
+
});
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return { line, attach };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Props = {
|
|
35
|
+
/** The current cue text (from `useCaption`). Empty between cues: the band
|
|
36
|
+
* stays, so the layout does not jump at every line. */
|
|
37
|
+
text: string;
|
|
38
|
+
/** BCP 47 tag of the captions ("en", "pt-BR"). */
|
|
39
|
+
lang: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Caption band of the Cuidado Global family (novo.creeta's `FaixaDeLegenda`).
|
|
45
|
+
* Fixed height of three lines so the page does not jump at every line. White
|
|
46
|
+
* on `ink` (creeta's petrol: 10.2:1). Hidden from screen readers: who neither
|
|
47
|
+
* hears nor sees uses the transcript on the page; here the text would change
|
|
48
|
+
* every two seconds.
|
|
49
|
+
*
|
|
50
|
+
* When it appears (the person pressed play) the page scrolls the least needed
|
|
51
|
+
* for the band to be in view: on phones it would be born behind the bottom
|
|
52
|
+
* bar, and the person would watch without knowing there are captions. The
|
|
53
|
+
* scroll margin is the bar's height.
|
|
54
|
+
*/
|
|
55
|
+
export function CaptionBand({ text, lang, className = "" }: Props) {
|
|
56
|
+
const band = useRef<HTMLParagraphElement>(null);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
60
|
+
band.current?.scrollIntoView?.({ block: "nearest", behavior: calm ? "auto" : "smooth" });
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<p
|
|
65
|
+
ref={band}
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
lang={lang}
|
|
68
|
+
data-caption-band=""
|
|
69
|
+
className={`mt-2 flex min-h-[4.75rem] scroll-mb-28 items-center justify-center rounded-[20px] bg-ink px-4 py-2.5 text-center text-[0.9375rem] font-medium leading-snug text-white md:scroll-mb-6 ${className}`}
|
|
70
|
+
>
|
|
71
|
+
{text}
|
|
72
|
+
</p>
|
|
73
|
+
);
|
|
74
|
+
}
|