@breathe-ds/ui 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +157 -142
- package/components/Accordion.tsx +11 -10
- package/components/AnchorNav.tsx +33 -21
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +69 -68
- package/components/BreatheMark.tsx +12 -11
- package/components/Button.tsx +12 -12
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/Curtain.tsx +152 -0
- package/components/Footer.tsx +186 -77
- package/components/Header.tsx +32 -17
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/Lockup.tsx +40 -39
- package/components/PageVideoHero.tsx +53 -53
- package/components/PillList.tsx +4 -4
- package/components/SectionKicker.tsx +3 -3
- package/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +7 -5
- package/theme.css +21 -21
- package/tokens/creeta.css +10 -11
- package/tokens/neutral.css +7 -7
- package/tokens/ttcab.css +8 -8
- package/components/Cortina.tsx +0 -152
|
@@ -3,31 +3,31 @@
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
5
|
type Props = {
|
|
6
|
-
/**
|
|
6
|
+
/** Short, muted, looping clip (file hosted by the site itself). */
|
|
7
7
|
src: string;
|
|
8
|
-
/**
|
|
8
|
+
/** Larger version, downloaded only on wide screens (from `from` up). */
|
|
9
9
|
srcLarge?: string;
|
|
10
|
-
/**
|
|
10
|
+
/** Minimum width (px) for the larger version. Defaults to 1024. */
|
|
11
11
|
from?: 768 | 1024;
|
|
12
|
-
/**
|
|
12
|
+
/** Still frame: shown before the video loads, without JS, and with reduced motion. */
|
|
13
13
|
poster: string;
|
|
14
|
-
/**
|
|
14
|
+
/** Still frame of the larger version, when it is a different video (e.g. vertical on mobile, landscape on desktop). */
|
|
15
15
|
posterLarge?: string;
|
|
16
|
-
/**
|
|
16
|
+
/** Zooms the video in to crop edges baked into the file. */
|
|
17
17
|
trimEdges?: boolean;
|
|
18
|
-
/** object-position
|
|
18
|
+
/** object-position of the video (e.g. "center 20%" to keep the face in a vertical clip). */
|
|
19
19
|
objectPosition?: string;
|
|
20
|
-
/**
|
|
20
|
+
/** Pause button labels (injectable for i18n). */
|
|
21
21
|
labels?: { pause?: string; play?: string };
|
|
22
22
|
className?: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* (save-data).
|
|
26
|
+
* Background video of the Cuidado Global family — mirrors creeta's `VideoFundo`
|
|
27
|
+
* with the Breathe tokens (ink/surface). Muted looping clip, purely decorative:
|
|
28
|
+
* hidden from screen readers (aria-hidden). Does not use the `autoplay`
|
|
29
|
+
* attribute: it only starts after checking prefers-reduced-motion and data
|
|
30
|
+
* saver (save-data). Has a pause button (WCAG 2.2.2) in the top-right corner.
|
|
31
31
|
*/
|
|
32
32
|
export function BackgroundVideo({
|
|
33
33
|
src,
|
|
@@ -93,7 +93,7 @@ export function BackgroundVideo({
|
|
|
93
93
|
<button
|
|
94
94
|
type="button"
|
|
95
95
|
onClick={toggle}
|
|
96
|
-
aria-label={running ? labels?.pause ?? "
|
|
96
|
+
aria-label={running ? labels?.pause ?? "Pause video" : labels?.play ?? "Play video"}
|
|
97
97
|
className="absolute top-4 right-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-ink/85 text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
98
98
|
>
|
|
99
99
|
{running ? (
|
package/components/BottomNav.tsx
CHANGED
|
@@ -2,75 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useId, useRef, useState, type ComponentType, type ElementType, type ReactNode } from "react";
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Icon in the lucide pattern (the component, not the node): the bar decides the size. */
|
|
6
6
|
export type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
8
|
+
/** One page of the site, in order of importance. The first ones stay on the bar;
|
|
9
|
+
* those that don't fit go into "More". */
|
|
10
10
|
export type BottomPage = {
|
|
11
11
|
href: string;
|
|
12
12
|
label: ReactNode;
|
|
13
13
|
Icon: IconType;
|
|
14
|
-
/**
|
|
14
|
+
/** Is this the current page? (the app decides: route/locale are its own) */
|
|
15
15
|
active?: boolean;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
18
|
+
/** Extra exit in the "More" panel (e.g. courses on another site): address written
|
|
19
|
+
* underneath and an outward arrow, as on novo.creeta. */
|
|
20
20
|
export type BottomExtra = {
|
|
21
21
|
href: string;
|
|
22
22
|
label: ReactNode;
|
|
23
23
|
Icon: IconType;
|
|
24
|
-
/**
|
|
24
|
+
/** Smaller line under the label (e.g. the domain). With `external` and without this, shows the href's domain. */
|
|
25
25
|
description?: ReactNode;
|
|
26
26
|
external?: boolean;
|
|
27
27
|
active?: boolean;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
type Labels = {
|
|
31
|
-
/** aria-label
|
|
31
|
+
/** aria-label of the bar */
|
|
32
32
|
nav: string;
|
|
33
|
-
/**
|
|
33
|
+
/** label of the "More" tab */
|
|
34
34
|
more: string;
|
|
35
|
-
/** aria-label
|
|
35
|
+
/** aria-label of the panel */
|
|
36
36
|
morePages: string;
|
|
37
|
-
/** sr-only
|
|
37
|
+
/** sr-only after "More": "more pages" */
|
|
38
38
|
morePagesSr: string;
|
|
39
|
-
/** sr-only
|
|
39
|
+
/** sr-only when the current page is inside "More" */
|
|
40
40
|
currentHere: string;
|
|
41
|
-
/** sr-only
|
|
41
|
+
/** sr-only on an external link */
|
|
42
42
|
newTab: string;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
nav: "
|
|
47
|
-
more: "
|
|
48
|
-
morePages: "
|
|
49
|
-
morePagesSr: "
|
|
50
|
-
currentHere: "(
|
|
51
|
-
newTab: "(
|
|
45
|
+
const EN: Labels = {
|
|
46
|
+
nav: "Main navigation",
|
|
47
|
+
more: "More",
|
|
48
|
+
morePages: "More pages",
|
|
49
|
+
morePagesSr: "more pages",
|
|
50
|
+
currentHere: "(the current page is here)",
|
|
51
|
+
newTab: "(opens in a new tab)",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
type Props = {
|
|
55
55
|
pages: BottomPage[];
|
|
56
56
|
extras?: BottomExtra[];
|
|
57
|
-
/**
|
|
57
|
+
/** The app's link component (e.g. next-intl Link). Default: <a>. */
|
|
58
58
|
linkComponent?: ElementType;
|
|
59
|
-
/**
|
|
59
|
+
/** From which width the header takes over and the bar disappears. creeta: `md`; ttcab: `lg`. */
|
|
60
60
|
hideFrom?: "md" | "lg";
|
|
61
|
-
/**
|
|
61
|
+
/** Strings (English defaults); the app passes the ones for its locale. */
|
|
62
62
|
labels?: Partial<Labels>;
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
63
|
+
/** Embedded in a `relative` container (demo, phone frame): bar, veil and
|
|
64
|
+
* panel are `absolute` inside it and show at any width. */
|
|
65
65
|
embedded?: boolean;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/** Minimum width of a tab, in px: below this the label doesn't fit. */
|
|
69
69
|
export const TAB_MIN_WIDTH = 64;
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
/** How many pages show directly on the bar, for a given usable width. The last
|
|
72
|
+
* tab is always "More" (that's where the pages that didn't fit and the extra
|
|
73
|
+
* exits live), so one page fewer than the number of tabs fits. */
|
|
74
74
|
export function howManyFit(width: number, total: number): number {
|
|
75
75
|
const tabs = Math.floor(width / TAB_MIN_WIDTH);
|
|
76
76
|
return Math.max(2, Math.min(total, tabs - 1));
|
|
@@ -83,9 +83,9 @@ const ArrowUpRight = () => (
|
|
|
83
83
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// Tab: icon+label column. The active one gets a SHAPE (light pill), not just color —
|
|
87
|
+
// readable for people who can't tell colors apart. Shrinks on touch, like an app button.
|
|
88
|
+
// The view-transition name makes the highlight slide between tabs on page change.
|
|
89
89
|
const tab = (active: boolean) =>
|
|
90
90
|
[
|
|
91
91
|
"flex h-14 w-full flex-col items-center justify-center gap-1 rounded-[20px] px-1 text-[10.5px] font-medium leading-none tracking-tight",
|
|
@@ -101,29 +101,29 @@ const row = (active?: boolean) =>
|
|
|
101
101
|
const bubble = "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink";
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
104
|
+
* Main navigation on mobile: floating bar within thumb reach, app-like. It is
|
|
105
|
+
* the code of novo.creeta's BarraNavegacao (and ttcab's BottomNav), made
|
|
106
|
+
* neutral through tokens: `ink` on the bar, `surface` on the active tab.
|
|
107
107
|
*
|
|
108
|
-
* -
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* -
|
|
112
|
-
* -
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* -
|
|
108
|
+
* - Dynamic: shows as many pages as fit the width (ResizeObserver, 64px per
|
|
109
|
+
* tab). The last tab is always "More", holding the rest and the extra
|
|
110
|
+
* exits. On a typical phone that's four pages plus "More".
|
|
111
|
+
* - Veil behind the bar: content fades out gradually before reaching it.
|
|
112
|
+
* - The "More" panel is a native popover: closes on Esc and on tap outside,
|
|
113
|
+
* returns focus to the button and opens even without JavaScript. Embedded
|
|
114
|
+
* (demo), it becomes an absolute panel inside the frame, with the same entrance.
|
|
115
|
+
* - Hidden from `hideFrom` up (md on creeta, lg on ttcab), where the header takes over.
|
|
116
116
|
*/
|
|
117
117
|
export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg", labels, embedded = false }: Props) {
|
|
118
118
|
const L: ElementType = linkComponent ?? "a";
|
|
119
|
-
const t = { ...
|
|
119
|
+
const t = { ...EN, ...labels };
|
|
120
120
|
const list = useRef<HTMLUListElement>(null);
|
|
121
121
|
const panel = useRef<HTMLDivElement>(null);
|
|
122
122
|
const wrap = useRef<HTMLDivElement>(null);
|
|
123
123
|
const panelId = useId();
|
|
124
|
-
//
|
|
124
|
+
// before measuring: the typical phone case (5 tabs = 4 pages + More)
|
|
125
125
|
const [visible, setVisible] = useState(() => howManyFit(5 * TAB_MIN_WIDTH, pages.length));
|
|
126
|
-
const [open, setOpen] = useState(false); //
|
|
126
|
+
const [open, setOpen] = useState(false); // embedded mode only
|
|
127
127
|
|
|
128
128
|
useEffect(() => {
|
|
129
129
|
const ul = list.current;
|
|
@@ -145,15 +145,15 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
145
145
|
try {
|
|
146
146
|
if (el?.matches(":popover-open")) el.hidePopover?.();
|
|
147
147
|
} catch {
|
|
148
|
-
/*
|
|
148
|
+
/* browser without popover support: nothing to close */
|
|
149
149
|
}
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
-
//
|
|
152
|
+
// Page changed: the panel closes.
|
|
153
153
|
const activeHref = pages.find((p) => p.active)?.href;
|
|
154
154
|
useEffect(close, [activeHref]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
155
155
|
|
|
156
|
-
//
|
|
156
|
+
// Embedded: closes on Esc and on tap outside (the native popover does this by itself).
|
|
157
157
|
useEffect(() => {
|
|
158
158
|
if (!embedded || !open) return;
|
|
159
159
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
@@ -173,11 +173,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
173
173
|
const inMore = pages.slice(visible);
|
|
174
174
|
const moreActive = inMore.some((p) => p.active) || extras.some((x) => x.active);
|
|
175
175
|
const pos = embedded ? "absolute" : "fixed";
|
|
176
|
-
|
|
176
|
+
// full class names (Tailwind only generates what it finds written in the code)
|
|
177
|
+
const hide = embedded ? "" : hideFrom === "md" ? " md:hidden" : " lg:hidden";
|
|
177
178
|
|
|
178
179
|
const panelInner = (
|
|
179
180
|
<nav aria-label={t.morePages}>
|
|
180
|
-
{/*
|
|
181
|
+
{/* One item per row: in two columns the panel got confusing. */}
|
|
181
182
|
{inMore.length > 0 && (
|
|
182
183
|
<ul className="grid list-none gap-1">
|
|
183
184
|
{inMore.map(({ href, label, Icon, active }, i) => (
|
|
@@ -223,8 +224,8 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
223
224
|
return (
|
|
224
225
|
<div ref={wrap} className={embedded ? "contents" : undefined}>
|
|
225
226
|
<style>{CSS}</style>
|
|
226
|
-
{/*
|
|
227
|
-
|
|
227
|
+
{/* Veil behind the bar: scrolling content fades out gradually before
|
|
228
|
+
reaching it, instead of showing up clipped by the edges. */}
|
|
228
229
|
<div
|
|
229
230
|
aria-hidden="true"
|
|
230
231
|
className={`pointer-events-none ${pos} inset-x-0 bottom-0 z-40 h-28 bg-gradient-to-t from-surface from-35% via-surface/85 via-65% to-transparent [view-transition-name:veu]${hide}`}
|
|
@@ -246,7 +247,7 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
246
247
|
</li>
|
|
247
248
|
))}
|
|
248
249
|
<li className="min-w-0 flex-1">
|
|
249
|
-
{/* popovertarget
|
|
250
|
+
{/* popovertarget in lowercase: React 18 doesn't know the attribute and passes it through as is. */}
|
|
250
251
|
<button
|
|
251
252
|
type="button"
|
|
252
253
|
{...(embedded ? { "aria-expanded": open, "aria-controls": panelId, onClick: () => setOpen((o) => !o) } : { popovertarget: panelId })}
|
|
@@ -262,12 +263,12 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
262
263
|
|
|
263
264
|
{embedded ? (
|
|
264
265
|
open && (
|
|
265
|
-
<div id={panelId} className="cg-
|
|
266
|
+
<div id={panelId} className="cg-more-panel cg-more-panel--embedded">
|
|
266
267
|
{panelInner}
|
|
267
268
|
</div>
|
|
268
269
|
)
|
|
269
270
|
) : (
|
|
270
|
-
<div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-
|
|
271
|
+
<div ref={panel} id={panelId} {...{ popover: "auto" }} className={`cg-more-panel cg-more-panel--${hideFrom}`}>
|
|
271
272
|
{panelInner}
|
|
272
273
|
</div>
|
|
273
274
|
)}
|
|
@@ -275,10 +276,10 @@ export function BottomNav({ pages, extras = [], linkComponent, hideFrom = "lg",
|
|
|
275
276
|
);
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
/*
|
|
279
|
-
|
|
279
|
+
/* "More" panel: right above the bar; enters growing from the corner of the
|
|
280
|
+
"More" button, in 0.18s (same as novo.creeta's .mais-painel). */
|
|
280
281
|
const CSS = `
|
|
281
|
-
.cg-
|
|
282
|
+
.cg-more-panel{
|
|
282
283
|
position:fixed; inset:auto .75rem calc(5.75rem + env(safe-area-inset-bottom)) .75rem;
|
|
283
284
|
width:auto; margin:0; border:0; border-radius:26px; padding:.5rem;
|
|
284
285
|
background:#fff; color:var(--color-ink);
|
|
@@ -286,15 +287,15 @@ const CSS = `
|
|
|
286
287
|
opacity:0; transform:translateY(8px) scale(.97); transform-origin:88% 100%;
|
|
287
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;
|
|
288
289
|
}
|
|
289
|
-
.cg-
|
|
290
|
-
@starting-style{ .cg-
|
|
291
|
-
.cg-
|
|
292
|
-
.cg-
|
|
290
|
+
.cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:1; transform:none; }
|
|
291
|
+
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ opacity:0; transform:translateY(8px) scale(.97); } }
|
|
292
|
+
.cg-more-panel::backdrop{ background:transparent; }
|
|
293
|
+
.cg-more-panel--embedded{ position:absolute; z-index:50; }
|
|
293
294
|
@media (prefers-reduced-motion:reduce){
|
|
294
|
-
.cg-
|
|
295
|
-
@starting-style{ .cg-
|
|
295
|
+
.cg-more-panel{ transform:none; transition:opacity 120ms ease; }
|
|
296
|
+
@starting-style{ .cg-more-panel:popover-open, .cg-more-panel--embedded{ transform:none; } }
|
|
296
297
|
}
|
|
297
|
-
/*
|
|
298
|
-
@media (min-width:768px){ .cg-
|
|
299
|
-
@media (min-width:1024px){ .cg-
|
|
298
|
+
/* From the breakpoint up the bar doesn't exist: neither does the panel. */
|
|
299
|
+
@media (min-width:768px){ .cg-more-panel--md{ display:none !important; } }
|
|
300
|
+
@media (min-width:1024px){ .cg-more-panel--lg{ display:none !important; } }
|
|
300
301
|
`;
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
type Props = {
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
/** Side in px (square). This is the size of the visible glyph: the SVG box
|
|
3
|
+
* is tight to the ring + dot, with no inner padding (the space between
|
|
4
|
+
* symbol and name is up to the layout). */
|
|
5
5
|
size?: number;
|
|
6
|
-
/**
|
|
6
|
+
/** Seconds of one full breathing cycle (inhale + exhale). */
|
|
7
7
|
duration?: number;
|
|
8
|
-
/**
|
|
8
|
+
/** Animate? (still respects prefers-reduced-motion). */
|
|
9
9
|
animated?: boolean;
|
|
10
10
|
className?: string;
|
|
11
11
|
title?: string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* "
|
|
15
|
+
* Living symbol of Breathe: an open ring (a fluid, continuous gesture) with a
|
|
16
|
+
* dot at the breath gap, an abstraction of the creeta logo (round shape + dot).
|
|
17
|
+
* NEUTRAL: uses currentColor and inherits the color from context (the palette
|
|
18
|
+
* comes from each app). It "breathes" with a soft scale pulse; respects
|
|
19
|
+
* prefers-reduced-motion.
|
|
19
20
|
*/
|
|
20
21
|
export function BreatheMark({
|
|
21
22
|
size = 96,
|
|
@@ -50,7 +51,7 @@ export function BreatheMark({
|
|
|
50
51
|
style={{ ["--breathe-dur" as string]: `${duration}s` }}
|
|
51
52
|
>
|
|
52
53
|
<g className="breathe-mark__g">
|
|
53
|
-
{/*
|
|
54
|
+
{/* open ring: a continuous gesture with a breath gap at the top */}
|
|
54
55
|
<path
|
|
55
56
|
d="M 42 17.5 A 17 17 0 1 1 22 17.5"
|
|
56
57
|
fill="none"
|
|
@@ -58,7 +59,7 @@ export function BreatheMark({
|
|
|
58
59
|
strokeWidth="8.5"
|
|
59
60
|
strokeLinecap="round"
|
|
60
61
|
/>
|
|
61
|
-
{/*
|
|
62
|
+
{/* the breath dot */}
|
|
62
63
|
<circle cx="32" cy="14.5" r="4.4" fill="currentColor" />
|
|
63
64
|
</g>
|
|
64
65
|
</svg>
|
package/components/Button.tsx
CHANGED
|
@@ -6,18 +6,18 @@ type Size = "sm" | "md" | "lg";
|
|
|
6
6
|
const pillBase =
|
|
7
7
|
"inline-flex items-center justify-center gap-2 rounded-pill font-body font-semibold whitespace-nowrap transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:pointer-events-none disabled:opacity-50";
|
|
8
8
|
|
|
9
|
-
/*
|
|
10
|
-
|
|
11
|
-
(currentColor),
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
/* `link` variant: underlined text action (not a pill) — the button's lightweight
|
|
10
|
+
sibling, same typography (font-body semibold). Inherits the color from the
|
|
11
|
+
context (currentColor), so it works on light and on dark backgrounds. The
|
|
12
|
+
underline sits at 50% and goes solid on hover; an arrow (icon passed as a
|
|
13
|
+
child) nudges up-and-right. Ignores `size` (it is inline, no pill). */
|
|
14
14
|
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
|
-
//
|
|
18
|
+
// solid petrol, cream text (e.g. "Falar com a equipe")
|
|
19
19
|
solid: "bg-ink text-surface hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.28)]",
|
|
20
|
-
//
|
|
20
|
+
// action color (orange/magenta) + on-action text (from the token, varies per site)
|
|
21
21
|
accent: "bg-action text-on-action hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.22)]",
|
|
22
22
|
outline: "border border-line text-ink hover:bg-ink/[0.05]",
|
|
23
23
|
ghost: "text-ink hover:bg-ink/[0.06]",
|
|
@@ -40,11 +40,11 @@ type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & { hre
|
|
|
40
40
|
type LinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Button
|
|
44
|
-
* (
|
|
45
|
-
*
|
|
46
|
-
* `link` (
|
|
47
|
-
* "
|
|
43
|
+
* Button of the Cuidado Global family. Polymorphic: with `href` it renders an
|
|
44
|
+
* `<a>` (inject your Link via a wrapper if you need a router), otherwise a
|
|
45
|
+
* `<button>`. Variants: solid (petrol), accent (action color), outline, ghost
|
|
46
|
+
* and `link` (underlined text action — e.g. "See all", "Secondary action",
|
|
47
|
+
* "View on Google Maps"; inherits the color, works on light and dark).
|
|
48
48
|
*/
|
|
49
49
|
export function Button(props: ButtonProps | LinkProps) {
|
|
50
50
|
const { variant = "solid", size = "md", className = "", children, ...rest } = props;
|
package/components/Card.tsx
CHANGED
|
@@ -2,17 +2,17 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
type Props = {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
/**
|
|
5
|
+
/** Renders as a link when href is passed. */
|
|
6
6
|
href?: string;
|
|
7
|
-
/**
|
|
7
|
+
/** Lifts on hover (for clickable cards). */
|
|
8
8
|
interactive?: boolean;
|
|
9
9
|
className?: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `interactive`
|
|
13
|
+
* Base card of the family — style decided in the DS: **subtle border + subtle
|
|
14
|
+
* shadow**, 2xl radius (28px), surface background. With `href` it becomes a
|
|
15
|
+
* link; with `interactive` it lifts on hover. No strong borders or heavy shadows.
|
|
16
16
|
*/
|
|
17
17
|
export function Card({ children, href, interactive, className = "" }: Props) {
|
|
18
18
|
const cls = `block rounded-2xl border border-line bg-surface p-[clamp(20px,3vw,32px)] shadow-[0_2px_12px_rgba(20,71,83,0.06)] ${
|
package/components/Container.tsx
CHANGED
|
@@ -2,15 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
type Props = {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
/**
|
|
5
|
+
/** Max width. "content" (default) for reading; "wide" for heroes/footer. */
|
|
6
6
|
width?: "content" | "wide";
|
|
7
7
|
className?: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Container
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Container of the Cuidado Global family — max width + consistent side gutter
|
|
12
|
+
* (16px on mobile). "content" for text/sections, "wide" for full-bleed blocks
|
|
13
|
+
* (heroes, footer).
|
|
14
14
|
*/
|
|
15
15
|
export function Container({ children, width = "content", className = "" }: Props) {
|
|
16
16
|
const max = width === "wide" ? "max-w-[1600px]" : "max-w-[1120px]";
|