@breathe-ds/ui 0.5.1 → 0.6.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 +33 -5
- package/components/Accordion.tsx +6 -3
- package/components/AnchorNav.tsx +10 -5
- package/components/Avatar.tsx +1 -1
- package/components/BottomNav.tsx +12 -5
- package/components/Button.tsx +5 -2
- package/components/Byline.tsx +25 -1
- package/components/Card.tsx +11 -3
- package/components/ContactRow.tsx +112 -0
- package/components/CourseCard.tsx +3 -3
- package/components/CtaCard.tsx +3 -1
- package/components/DateBadge.tsx +1 -1
- package/components/EmptyState.tsx +54 -0
- package/components/EventFacts.tsx +1 -1
- package/components/EventRow.tsx +20 -8
- package/components/FactStrip.tsx +1 -1
- package/components/FilterBar.tsx +50 -0
- package/components/FilterChips.tsx +63 -0
- package/components/FilterPill.tsx +35 -0
- package/components/Footer.tsx +1 -1
- package/components/Header.tsx +61 -33
- package/components/Heading.tsx +3 -2
- package/components/InfoCard.tsx +7 -2
- package/components/List.tsx +4 -2
- package/components/PageHero.tsx +74 -0
- package/components/PageVideoHero.tsx +21 -6
- package/components/PersonChip.tsx +1 -1
- package/components/Pill.tsx +1 -1
- package/components/ProfessionalCard.tsx +60 -5
- package/components/ProfileSpotlight.tsx +94 -0
- package/components/PublicationCard.tsx +215 -0
- package/components/PullQuote.tsx +87 -0
- package/components/RegistrationBox.tsx +1 -1
- package/components/SearchField.tsx +85 -0
- package/components/SelectField.tsx +75 -0
- package/components/SidePanel.tsx +6 -3
- package/components/StickyGroupHeading.tsx +38 -0
- package/components/TestimonialCard.tsx +179 -23
- package/components/Text.tsx +1 -1
- package/components/Timeline.tsx +1 -1
- package/components/VideoInviteCard.tsx +20 -6
- package/package.json +14 -2
- package/theme.css +50 -0
- package/tokens/creeta.css +1 -0
- package/tokens/ttcab.css +6 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** The filters (compact SelectField density="sm", chips…), on the left. */
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** On the right: the count and a "clear" action. */
|
|
9
|
+
aside?: ReactNode;
|
|
10
|
+
/** Accessible name of the bar ("Filtrar por local"). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Where it sticks, below the site's sticky header. A literal Tailwind class
|
|
13
|
+
* written in your source (`top-20` = an 80px header). */
|
|
14
|
+
stickyTop?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* FilterBar — the thin bar of refining filters that comes after a search
|
|
20
|
+
* (Apple's store finder, Contacts): compact pills on the left, the count and
|
|
21
|
+
* "clear" on the right. It sticks below the header while the list scrolls; on
|
|
22
|
+
* the page colour, translucent, with a hairline under it.
|
|
23
|
+
*
|
|
24
|
+
* It measures itself and publishes its height as `--cg-filterbar-h` on the
|
|
25
|
+
* document, so headings that stick below it can use
|
|
26
|
+
* `top-[calc(80px+var(--cg-filterbar-h))]` and never slide under it, even when
|
|
27
|
+
* the bar wraps on a phone.
|
|
28
|
+
*/
|
|
29
|
+
export function FilterBar({ children, aside, label, stickyTop = "top-20", className = "" }: Props) {
|
|
30
|
+
const box = useRef<HTMLDivElement>(null);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = box.current;
|
|
33
|
+
if (!el || typeof ResizeObserver !== "function") return;
|
|
34
|
+
const root = document.documentElement;
|
|
35
|
+
const ro = new ResizeObserver(() => root.style.setProperty("--cg-filterbar-h", `${el.offsetHeight}px`));
|
|
36
|
+
ro.observe(el);
|
|
37
|
+
return () => {
|
|
38
|
+
ro.disconnect();
|
|
39
|
+
root.style.removeProperty("--cg-filterbar-h");
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
return (
|
|
43
|
+
<div ref={box} role="group" aria-label={label} className={`sticky ${stickyTop} z-20 -mx-4 border-b border-line bg-surface/90 px-4 py-3 backdrop-blur-md md:-mx-6 md:px-6 ${className}`}>
|
|
44
|
+
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
|
45
|
+
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center">{children}</div>
|
|
46
|
+
{aside && <div className="flex items-center gap-4 text-sm text-ink-soft">{aside}</div>}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FilterChip = {
|
|
4
|
+
value: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** Shown after the label, lighter ("Tratamento 8"). */
|
|
7
|
+
count?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
/** Names the group for screen readers ("Filtrar por tema"). */
|
|
12
|
+
label: string;
|
|
13
|
+
chips: FilterChip[];
|
|
14
|
+
/** The selected value; "" is the first chip ("Todos"). */
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
/** `wrap` (default): the chips wrap to new lines. `scroll`: on phones they
|
|
18
|
+
* stay on one line and scroll sideways (bleeding to the screen edge), so a
|
|
19
|
+
* long topic list does not push the content down; from `sm` they wrap. */
|
|
20
|
+
layout?: "wrap" | "scroll";
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* FilterChips — one-of-many filter over a list (the topics of a library, the
|
|
26
|
+
* categories of a blog). Toggle buttons with `aria-pressed` in a labelled
|
|
27
|
+
* group: they change what is shown, they do not navigate, so they are not
|
|
28
|
+
* links and not tabs. 44px tall for the finger.
|
|
29
|
+
*
|
|
30
|
+
* Off: the white pill of the family (`card`, soft shadow, `ink` text). On:
|
|
31
|
+
* filled `ink` with `surface` text (9.41:1 on ttcab). A small control never
|
|
32
|
+
* uses the action colour as its fill (see the Action dose rule).
|
|
33
|
+
*/
|
|
34
|
+
const SCROLL =
|
|
35
|
+
"max-sm:-mx-5 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:px-5 max-sm:py-1 max-sm:[scrollbar-width:none] max-sm:[&::-webkit-scrollbar]:hidden max-sm:snap-x max-sm:scroll-px-5 [&>button]:shrink-0 [&>button]:snap-start";
|
|
36
|
+
|
|
37
|
+
export function FilterChips({ label, chips, value, onValueChange, layout = "wrap", className = "" }: Props) {
|
|
38
|
+
return (
|
|
39
|
+
<div role="group" aria-label={label} className={`flex flex-wrap gap-2 ${layout === "scroll" ? SCROLL : ""} ${className}`}>
|
|
40
|
+
{chips.map((c) => {
|
|
41
|
+
const on = c.value === value;
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={c.value || "all"}
|
|
45
|
+
type="button"
|
|
46
|
+
aria-pressed={on}
|
|
47
|
+
onClick={() => onValueChange(c.value)}
|
|
48
|
+
className={`inline-flex min-h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 ${
|
|
49
|
+
on
|
|
50
|
+
? "bg-ink text-surface"
|
|
51
|
+
: "bg-card text-ink shadow-[0_1px_2px_color-mix(in_srgb,var(--color-shadow)_18%,transparent)] hover:bg-tint"
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
{c.label}
|
|
55
|
+
{c.count !== undefined && (
|
|
56
|
+
<span className={`font-normal tabular-nums ${on ? "text-surface/80" : "text-ink-soft"}`}>{c.count}</span>
|
|
57
|
+
)}
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** What is filtered: "Santa Catarina". */
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
onRemove: () => void;
|
|
8
|
+
/** aria-label of the pill ("Remover filtro Santa Catarina"). */
|
|
9
|
+
removeLabel: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FilterPill — one active filter, removable (the token in a search bar:
|
|
15
|
+
* "Santa Catarina ✕"). The whole pill is the button: a bigger target than a
|
|
16
|
+
* lone ✕, 36px tall inside a 44px row. `ink` fill with `surface` text, the
|
|
17
|
+
* same "on" state as FilterChips (9.41:1 on ttcab), so active filters read the
|
|
18
|
+
* same everywhere. Pair it with a "Clear filters" link when there can be more
|
|
19
|
+
* than one.
|
|
20
|
+
*/
|
|
21
|
+
export function FilterPill({ children, onRemove, removeLabel, className = "" }: Props) {
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
onClick={onRemove}
|
|
26
|
+
aria-label={removeLabel}
|
|
27
|
+
className={`group/pill inline-flex min-h-9 items-center gap-1.5 rounded-full bg-ink py-1 pr-2 pl-3.5 text-sm font-semibold text-surface transition-colors duration-150 hover:bg-ink-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
28
|
+
>
|
|
29
|
+
<span aria-hidden="true">{children}</span>
|
|
30
|
+
<span aria-hidden="true" className="flex h-6 w-6 items-center justify-center rounded-full bg-surface/15 transition-colors duration-150 group-hover/pill:bg-surface/25">
|
|
31
|
+
<X size={14} strokeWidth={2.25} />
|
|
32
|
+
</span>
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/components/Footer.tsx
CHANGED
|
@@ -77,7 +77,7 @@ const link =
|
|
|
77
77
|
const strongLink =
|
|
78
78
|
"inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
79
79
|
const chip =
|
|
80
|
-
"inline-flex min-h-11 items-center gap-2 rounded-full bg-
|
|
80
|
+
"inline-flex min-h-11 items-center gap-2 rounded-full bg-tint px-4 text-sm font-medium text-ink transition-colors duration-200 hover:bg-ink hover:text-surface motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Footer of the Cuidado Global family — the site-footer of novo.creeta, by
|
package/components/Header.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, type ReactNode, type ElementType } from "react";
|
|
3
|
+
import { useId, useRef, useState, type ReactNode, type ElementType } from "react";
|
|
4
4
|
|
|
5
5
|
export type NavChild = {
|
|
6
6
|
label: ReactNode;
|
|
@@ -20,6 +20,12 @@ export type NavItem = {
|
|
|
20
20
|
/** Final link of the dropdown. Arrow → (goes to another page of the site); with
|
|
21
21
|
* `external` it becomes ↗ (leaves to another site), as in the other components. */
|
|
22
22
|
seeAll?: { label: ReactNode; href: string; external?: boolean };
|
|
23
|
+
/** Instead of `children`: labelled groups of links (e.g. "Navigation", "Content"). */
|
|
24
|
+
groups?: { label: ReactNode; children: NavChild[] }[];
|
|
25
|
+
/** Long dropdowns: 2 columns of compact rows (max 2, as in the rest of the DS). */
|
|
26
|
+
columns?: 1 | 2;
|
|
27
|
+
/** Which edge of the trigger the dropdown aligns to; "end" keeps a wide menu inside the viewport. */
|
|
28
|
+
align?: "start" | "end";
|
|
23
29
|
};
|
|
24
30
|
|
|
25
31
|
type Labels = {
|
|
@@ -72,11 +78,44 @@ const linkCls = (active?: boolean) =>
|
|
|
72
78
|
|
|
73
79
|
function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels }) {
|
|
74
80
|
const [open, setOpen] = useState(false);
|
|
81
|
+
const two = item.columns === 2;
|
|
82
|
+
const gid = useId();
|
|
83
|
+
// a mouse that hovered already opened the menu: its click on the chevron must not close it
|
|
84
|
+
const hovering = useRef(false);
|
|
85
|
+
const renderChild = (c: NavChild) => (
|
|
86
|
+
<L
|
|
87
|
+
href={c.href}
|
|
88
|
+
onClick={() => setOpen(false)}
|
|
89
|
+
// with a description the icon sits with the label's first line (creeta);
|
|
90
|
+
// label only: centred on the bubble, and a two-line label stays tight
|
|
91
|
+
className={`flex ${c.description ? "items-start" : "items-center"} gap-3 rounded-xl px-2.5 ${item.groups ? "py-1" : two ? "py-1.5" : "py-2"} transition-colors hover:bg-ink/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
|
|
92
|
+
>
|
|
93
|
+
{c.icon && (
|
|
94
|
+
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-tint text-ink">
|
|
95
|
+
{c.icon}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
<span>
|
|
99
|
+
<span className={`block font-heading ${two ? "text-[0.9rem]" : "text-[0.95rem]"} leading-snug font-semibold text-ink`}>
|
|
100
|
+
{c.label}
|
|
101
|
+
</span>
|
|
102
|
+
{c.description && (
|
|
103
|
+
<span className="mt-0.5 block text-sm text-ink-soft">{c.description}</span>
|
|
104
|
+
)}
|
|
105
|
+
</span>
|
|
106
|
+
</L>
|
|
107
|
+
);
|
|
75
108
|
return (
|
|
76
109
|
<div
|
|
77
110
|
className="relative"
|
|
78
|
-
onMouseEnter={() =>
|
|
79
|
-
|
|
111
|
+
onMouseEnter={() => {
|
|
112
|
+
hovering.current = true;
|
|
113
|
+
setOpen(true);
|
|
114
|
+
}}
|
|
115
|
+
onMouseLeave={() => {
|
|
116
|
+
hovering.current = false;
|
|
117
|
+
setOpen(false);
|
|
118
|
+
}}
|
|
80
119
|
onBlur={(e) => {
|
|
81
120
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
|
|
82
121
|
}}
|
|
@@ -94,46 +133,35 @@ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels })
|
|
|
94
133
|
type="button"
|
|
95
134
|
aria-expanded={open}
|
|
96
135
|
aria-label={t.openSubmenu}
|
|
97
|
-
onClick={() => setOpen((o) => !o)}
|
|
136
|
+
onClick={() => setOpen((o) => (hovering.current ? true : !o))}
|
|
98
137
|
className="flex min-h-11 items-center rounded-md px-0.5 text-ink/70 transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
99
138
|
>
|
|
100
139
|
<Chevron open={open} />
|
|
101
140
|
</button>
|
|
102
141
|
</span>
|
|
103
142
|
|
|
104
|
-
<div className={`absolute top-full left-0 z-50 pt-2 ${open ? "" : "pointer-events-none"}`}>
|
|
143
|
+
<div className={`absolute top-full ${item.align === "end" ? "right-0" : "left-0"} z-50 pt-2 ${open ? "" : "pointer-events-none"}`}>
|
|
105
144
|
<ul
|
|
106
|
-
className={
|
|
145
|
+
className={`${two ? (item.groups ? "w-[34rem] columns-2 gap-x-4" : "grid w-[34rem] grid-cols-2 gap-x-1") : "w-[19rem]"} list-none rounded-2xl border border-line bg-surface p-2 shadow-[0_18px_40px_-16px_color-mix(in_srgb,var(--color-shadow)_40%,transparent)] transition duration-150 ease-out motion-reduce:transition-none ${
|
|
107
146
|
open ? "visible scale-100 opacity-100" : "invisible -translate-y-1 scale-[0.98] opacity-0"
|
|
108
147
|
}`}
|
|
109
148
|
>
|
|
110
|
-
{item.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<span>
|
|
125
|
-
<span className="block font-heading text-[0.95rem] leading-snug font-semibold text-ink">
|
|
126
|
-
{c.label}
|
|
127
|
-
</span>
|
|
128
|
-
{c.description && (
|
|
129
|
-
<span className="mt-0.5 block text-sm text-ink-soft">{c.description}</span>
|
|
130
|
-
)}
|
|
131
|
-
</span>
|
|
132
|
-
</L>
|
|
133
|
-
</li>
|
|
134
|
-
))}
|
|
149
|
+
{item.groups
|
|
150
|
+
? item.groups.map((g, gi) => (
|
|
151
|
+
<li key={gi} className="break-inside-avoid pb-5 last:pb-1">
|
|
152
|
+
<p id={`${gid}-${gi}`} className="px-2.5 pt-2 pb-0.5 font-heading text-[0.7rem] font-bold tracking-[0.12em] text-ink-soft uppercase">
|
|
153
|
+
{g.label}
|
|
154
|
+
</p>
|
|
155
|
+
<ul aria-labelledby={`${gid}-${gi}`} className="list-none">
|
|
156
|
+
{g.children.map((c, i) => (
|
|
157
|
+
<li key={i}>{renderChild(c)}</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
</li>
|
|
161
|
+
))
|
|
162
|
+
: item.children!.map((c, i) => <li key={i}>{renderChild(c)}</li>)}
|
|
135
163
|
{item.seeAll && (
|
|
136
|
-
<li className=
|
|
164
|
+
<li className={`mt-1 border-t border-line pt-1 ${two ? "col-span-2 [column-span:all]" : ""}`}>
|
|
137
165
|
<L
|
|
138
166
|
href={item.seeAll.href}
|
|
139
167
|
onClick={() => setOpen(false)}
|
|
@@ -168,7 +196,7 @@ export function Header({ logo, items, linkComponent, cta, localeSwitcher, labels
|
|
|
168
196
|
|
|
169
197
|
<nav aria-label={t.nav} className="hidden items-center gap-6 lg:flex xl:gap-7">
|
|
170
198
|
{items.map((item, i) =>
|
|
171
|
-
item.children?.length ? (
|
|
199
|
+
item.children?.length || item.groups?.length ? (
|
|
172
200
|
<Dropdown key={i} item={item} L={L} t={t} />
|
|
173
201
|
) : (
|
|
174
202
|
<L key={i} href={item.href} aria-current={item.active ? "page" : undefined} className={linkCls(item.active)}>
|
package/components/Heading.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// "p": heading look without being a heading (a pull quote, a display line)
|
|
4
|
+
type Level = "h1" | "h2" | "h3" | "h4" | "p";
|
|
4
5
|
/** The family's heading scale (novo.creeta):
|
|
5
6
|
* display — page title (SectionHeading as h1) · hero — title over a full-bleed photo/video
|
|
6
7
|
* section — section title (h2) · card — title inside a card · small — h3 */
|
|
@@ -13,7 +14,7 @@ export const HEADING_SIZE: Record<HeadingSize, string> = {
|
|
|
13
14
|
card: "text-[1.375rem] leading-snug md:text-2xl",
|
|
14
15
|
small: "text-xl leading-snug",
|
|
15
16
|
};
|
|
16
|
-
const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small" };
|
|
17
|
+
const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small", p: "section" };
|
|
17
18
|
|
|
18
19
|
type HeadingProps = {
|
|
19
20
|
as?: Level;
|
package/components/InfoCard.tsx
CHANGED
|
@@ -40,6 +40,11 @@ const ArrowUpRight = () => (
|
|
|
40
40
|
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
+
// internal page link: arrow forward (the ↗ is reserved for leaving the site, as in the other components)
|
|
44
|
+
const ArrowRight = () => (
|
|
45
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
|
46
|
+
);
|
|
47
|
+
|
|
43
48
|
const body = "mt-2 leading-relaxed text-ink/80";
|
|
44
49
|
const link =
|
|
45
50
|
"mt-4 inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
@@ -71,7 +76,7 @@ export function InfoCard({
|
|
|
71
76
|
|
|
72
77
|
return (
|
|
73
78
|
<Tag className={`rounded-2xl bg-card p-6 md:p-8 ${className}`}>
|
|
74
|
-
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-
|
|
79
|
+
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-tint text-ink">
|
|
75
80
|
<Icon size={20} strokeWidth={1.75} />
|
|
76
81
|
</span>
|
|
77
82
|
<Heading className="mt-4 font-heading text-xl font-medium text-ink">{title}</Heading>
|
|
@@ -106,7 +111,7 @@ export function InfoCard({
|
|
|
106
111
|
) : (
|
|
107
112
|
<L href={l.href} className={link}>
|
|
108
113
|
{l.label}
|
|
109
|
-
<
|
|
114
|
+
<ArrowRight />
|
|
110
115
|
</L>
|
|
111
116
|
)}
|
|
112
117
|
</span>
|
package/components/List.tsx
CHANGED
|
@@ -43,15 +43,17 @@ const ArrowUpRight = () => (
|
|
|
43
43
|
export function List({ items, marker = "check", linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
|
|
44
44
|
const L: ElementType = linkComponent ?? "a";
|
|
45
45
|
const bubble = (
|
|
46
|
-
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-
|
|
46
|
+
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
|
|
47
47
|
<Check />
|
|
48
48
|
</span>
|
|
49
49
|
);
|
|
50
50
|
const mark = marker === "check" ? bubble : <CornerDownRight />;
|
|
51
51
|
const gap = marker === "check" ? "gap-3" : "gap-2.5";
|
|
52
52
|
|
|
53
|
+
// leading-snug (1.375): in a two-line item the lines stay closer than the
|
|
54
|
+
// items are to each other; relaxed leading read as air inside the item (2026-10-29)
|
|
53
55
|
return (
|
|
54
|
-
<ul className={`list-none ${marker === "check" ? "space-y-3 text-ink" : "space-y-2
|
|
56
|
+
<ul className={`list-none leading-snug ${marker === "check" ? "space-y-3 text-ink" : "space-y-2.5 text-ink/80"} ${className}`}>
|
|
55
57
|
{items.map((it, i) => (
|
|
56
58
|
<li key={`${i}-${it.href ?? ""}`}>
|
|
57
59
|
{it.href ? (
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Heading } from "./Heading";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
/** Supporting paragraph under the title. */
|
|
7
|
+
lead?: ReactNode;
|
|
8
|
+
/** A smaller line under the lead ("Os artigos estão no idioma original…"). */
|
|
9
|
+
note?: ReactNode;
|
|
10
|
+
/** Above the title: a Breadcrumb. */
|
|
11
|
+
breadcrumb?: ReactNode;
|
|
12
|
+
/** The page's main controls (search, filters), inside the panel: the hero
|
|
13
|
+
* and the tools that act on the page read as one region. */
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
/** The site's graphic device, low in the corner (e.g. the brand symbol at
|
|
16
|
+
* 6% in `ink`). Decorative: rendered aria-hidden. */
|
|
17
|
+
watermark?: ReactNode;
|
|
18
|
+
/** `alt` (default): the section band colour (`surface-alt`), light, ink
|
|
19
|
+
* text at full contrast (8.85:1 on ttcab). `card`: white. */
|
|
20
|
+
tone?: "alt" | "card";
|
|
21
|
+
/** `panel` (default): the rounded band with the tools inside. `centered`: the
|
|
22
|
+
* Apple search page — no box, title and lead centred, one search below on the
|
|
23
|
+
* page colour; refining filters live outside, after the results begin. */
|
|
24
|
+
variant?: "panel" | "centered";
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PageHero — the opening panel of an inner page (a library, a directory).
|
|
30
|
+
* Wide, in novo.creeta's proportion (up to 1600px, 16/24px from the screen
|
|
31
|
+
* edge), rounded 28/32px, with the content aligned to the site's column
|
|
32
|
+
* (max-w-6xl) so the title lines up with the list below.
|
|
33
|
+
*
|
|
34
|
+
* Light on purpose: the white search field and chips stand out on the band,
|
|
35
|
+
* and the panel groups title, explanation and controls (common region)
|
|
36
|
+
* without the weight of a dark panel.
|
|
37
|
+
*/
|
|
38
|
+
export function PageHero({ title, lead, note, breadcrumb, children, watermark, tone = "alt", variant = "panel", className = "" }: Props) {
|
|
39
|
+
if (variant === "centered") {
|
|
40
|
+
return (
|
|
41
|
+
<section className={`px-4 pt-[clamp(40px,7vw,96px)] pb-[clamp(28px,4vw,48px)] text-center md:px-6 ${className}`}>
|
|
42
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
43
|
+
{breadcrumb && <div className="mb-6 flex justify-center">{breadcrumb}</div>}
|
|
44
|
+
<Heading as="h1" size="display" className="text-balance">
|
|
45
|
+
{title}
|
|
46
|
+
</Heading>
|
|
47
|
+
{lead && <p className="mx-auto m-0 mt-4 max-w-xl text-lg leading-relaxed text-balance text-ink-body">{lead}</p>}
|
|
48
|
+
{note && <p className="mx-auto m-0 mt-3 max-w-xl text-sm leading-relaxed text-ink-soft">{note}</p>}
|
|
49
|
+
{children && <div className="mx-auto mt-[clamp(24px,4vw,40px)] max-w-2xl text-left">{children}</div>}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<div className={`px-4 pt-2 md:px-6 md:pt-4 ${className}`}>
|
|
56
|
+
<section className={`relative isolate mx-auto max-w-[1600px] overflow-hidden rounded-[28px] md:rounded-[32px] ${tone === "card" ? "bg-card" : "bg-surface-alt"}`}>
|
|
57
|
+
{watermark && (
|
|
58
|
+
<span aria-hidden="true" className="pointer-events-none absolute -right-10 -bottom-16 -z-10 hidden w-[min(34vw,420px)] md:block">
|
|
59
|
+
{watermark}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
<div className="mx-auto w-full max-w-6xl px-5 py-[clamp(36px,6vw,72px)] sm:px-6 lg:px-8">
|
|
63
|
+
{breadcrumb && <div className="mb-6">{breadcrumb}</div>}
|
|
64
|
+
<Heading as="h1" size="display" className="text-balance">
|
|
65
|
+
{title}
|
|
66
|
+
</Heading>
|
|
67
|
+
{lead && <p className="m-0 mt-4 max-w-xl text-lg leading-relaxed text-ink-body">{lead}</p>}
|
|
68
|
+
{note && <p className="m-0 mt-3 max-w-xl text-sm leading-relaxed text-ink-soft">{note}</p>}
|
|
69
|
+
{children && <div className="mt-[clamp(24px,4vw,40px)]">{children}</div>}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { BackgroundVideo } from "./BackgroundVideo";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** The glass panel: kicker, title, lead and, when the page has more than one
|
|
7
|
+
* call to action, the buttons inside the panel (the bottom-right slot fits
|
|
8
|
+
* one button beside the panel; two would run into the title). */
|
|
9
|
+
type Panel = { kicker?: ReactNode; title: ReactNode; lead?: ReactNode; actions?: ReactNode };
|
|
7
10
|
|
|
8
11
|
/** Local background clip (loop mode): muted, looping. */
|
|
9
12
|
type LoopPreview = {
|
|
@@ -64,6 +67,9 @@ type Props = {
|
|
|
64
67
|
/** Labels of the background pause button (player mode, muted preview). */
|
|
65
68
|
previewLabels?: { pause?: string; play?: string };
|
|
66
69
|
videoTitle?: string;
|
|
70
|
+
/** The page's AnchorNav comes right after the hero: the hero gives up the
|
|
71
|
+
* capsule's height so both fit in the first fold. */
|
|
72
|
+
withAnchorNav?: boolean;
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
function PlayGlyph({ size = 20 }: { size?: number }) {
|
|
@@ -172,8 +178,14 @@ export function PageVideoHero({
|
|
|
172
178
|
closeLabel = "Close video",
|
|
173
179
|
previewLabels,
|
|
174
180
|
videoTitle,
|
|
181
|
+
withAnchorNav = false,
|
|
175
182
|
}: Props) {
|
|
176
183
|
const isPlayer = video.mode === "player";
|
|
184
|
+
// something sits in the bottom-right corner: the panel leaves room for it (md+)
|
|
185
|
+
const corner = actions === null ? false : actions ? true : isPlayer;
|
|
186
|
+
// phones: with a panel, the default watch button moves to the top-left corner
|
|
187
|
+
// and the panel keeps room for it (pt-24), so the two never overlap
|
|
188
|
+
const watchOnTop = Boolean(panel) && !topLeft && actions === undefined && isPlayer;
|
|
177
189
|
const [playing, setPlaying] = useState(false);
|
|
178
190
|
const watchRef = useRef<HTMLButtonElement>(null);
|
|
179
191
|
const playedOnce = useRef(false);
|
|
@@ -188,7 +200,7 @@ export function PageVideoHero({
|
|
|
188
200
|
|
|
189
201
|
return (
|
|
190
202
|
<div className="px-4 pt-2 md:px-6 md:pt-6">
|
|
191
|
-
<section className=
|
|
203
|
+
<section className={`relative isolate mx-auto flex max-w-[1600px] flex-col justify-end overflow-hidden rounded-[32px] bg-ink text-surface ${withAnchorNav ? "min-h-[min(calc(100svh-13.5rem),560px)] lg:min-h-[min(calc(100svh-13.5rem),820px)]" : "min-h-[min(calc(100svh-7.5rem),560px)] lg:min-h-[min(calc(100svh-7.5rem),820px)]"}`}>
|
|
192
204
|
{/* media — background (preview) or, when playing, the full video with sound */}
|
|
193
205
|
<div className={`absolute inset-x-0 top-0 ${playing ? "bottom-[4.5rem]" : "bottom-0 -z-10"}`}>
|
|
194
206
|
{playing ? (
|
|
@@ -247,6 +259,7 @@ export function PageVideoHero({
|
|
|
247
259
|
posterLarge={(video as VideoLoop).preview.posterLarge}
|
|
248
260
|
trimEdges={(video as VideoLoop).preview.trimEdges}
|
|
249
261
|
objectPosition={(video as VideoLoop).preview.objectPosition}
|
|
262
|
+
labels={previewLabels}
|
|
250
263
|
className="absolute inset-0"
|
|
251
264
|
/>
|
|
252
265
|
)}
|
|
@@ -263,9 +276,9 @@ export function PageVideoHero({
|
|
|
263
276
|
{topLeft && <div className="absolute top-4 left-4 z-10">{topLeft}</div>}
|
|
264
277
|
|
|
265
278
|
{/* panel / caption — in flow (justify-end), m-6 */}
|
|
266
|
-
<div className=
|
|
279
|
+
<div className={`relative z-10 p-6 md:m-6 md:p-0 ${watchOnTop ? "pt-24" : ""}`}>
|
|
267
280
|
{panel ? (
|
|
268
|
-
<div className=
|
|
281
|
+
<div className={`inline-flex max-w-full items-center rounded-[24px] bg-ink/75 px-6 py-4 backdrop-blur-md lg:px-7 lg:py-5 ${corner ? "md:max-w-[calc(100%-16rem)]" : ""}`}>
|
|
269
282
|
<div>
|
|
270
283
|
{panel.kicker && (
|
|
271
284
|
<p className="mb-3 font-heading text-[0.82rem] font-bold tracking-[0.1em] text-surface/80 uppercase">{panel.kicker}</p>
|
|
@@ -274,6 +287,7 @@ export function PageVideoHero({
|
|
|
274
287
|
{panel.lead && (
|
|
275
288
|
<p className="mt-4 max-w-[46ch] text-base leading-[1.55] text-pretty text-surface/85 md:text-lg">{panel.lead}</p>
|
|
276
289
|
)}
|
|
290
|
+
{panel.actions && <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">{panel.actions}</div>}
|
|
277
291
|
</div>
|
|
278
292
|
</div>
|
|
279
293
|
) : caption ? (
|
|
@@ -281,7 +295,8 @@ export function PageVideoHero({
|
|
|
281
295
|
) : null}
|
|
282
296
|
</div>
|
|
283
297
|
|
|
284
|
-
{/* bottom-right action
|
|
298
|
+
{/* bottom-right action (on phones, with a panel, the watch button goes to the
|
|
299
|
+
free top-left corner so it never covers the panel's buttons) */}
|
|
285
300
|
{actions === null ? null : actions ? (
|
|
286
301
|
<div className="absolute right-4 bottom-4 z-10 flex flex-wrap items-center justify-end gap-2 md:right-6 md:bottom-6">{actions}</div>
|
|
287
302
|
) : isPlayer ? (
|
|
@@ -289,7 +304,7 @@ export function PageVideoHero({
|
|
|
289
304
|
ref={watchRef}
|
|
290
305
|
type="button"
|
|
291
306
|
onClick={() => setPlaying(true)}
|
|
292
|
-
className=
|
|
307
|
+
className={`absolute z-10 flex items-center gap-3 rounded-full border border-surface/15 bg-ink/80 py-2 pr-7 pl-2 font-body font-semibold text-surface backdrop-blur-md transition-colors duration-200 hover:bg-ink/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none md:top-auto md:left-auto md:right-6 md:bottom-6 ${watchOnTop ? "top-4 left-4" : "right-4 bottom-4"}`}
|
|
293
308
|
>
|
|
294
309
|
<span className="relative flex h-12 w-12 items-center justify-center rounded-full bg-surface text-ink">
|
|
295
310
|
<PlayGlyph size={20} />
|
|
@@ -40,7 +40,7 @@ export function PersonChip({ name, description, photo, avatar, href, linkCompone
|
|
|
40
40
|
className={`${box} pr-1.5 transition-transform duration-150 ease-out active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none motion-reduce:active:scale-100 ${className}`}
|
|
41
41
|
>
|
|
42
42
|
{inner}
|
|
43
|
-
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-
|
|
43
|
+
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-tint text-ink">
|
|
44
44
|
<svg viewBox="0 0 24 24" width={15} height={15} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
45
45
|
</span>
|
|
46
46
|
</L>
|
package/components/Pill.tsx
CHANGED
|
@@ -29,7 +29,7 @@ export function Pill({ href, children, meta, linkComponent, className = "" }: Pr
|
|
|
29
29
|
<span
|
|
30
30
|
aria-hidden="true"
|
|
31
31
|
data-arrow={onPage ? "down" : "other-page"}
|
|
32
|
-
className="ml-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-
|
|
32
|
+
className="ml-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-tint transition-colors duration-200 group-hover:bg-surface/15 motion-reduce:transition-none"
|
|
33
33
|
>
|
|
34
34
|
{onPage ? (
|
|
35
35
|
<svg viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-down" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M19 12l-7 7-7-7" /></svg>
|