@breathe-ds/ui 0.6.7 → 0.6.8

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 CHANGED
@@ -162,6 +162,8 @@ files (`public/brand`) in the repositories, split into two components so the
162
162
 
163
163
  ```tsx
164
164
  <Curtain logo={<TtcabSymbol />} name={<TtcabWordmark />} wordmarkRatio={TTCAB_LOCKUP.wordmarkPerSymbol} tagline="…" />
165
+ // once per session: the head script, from its own module (callable in a server layout, 0.6.8)
166
+ // import { curtainHeadScript } from "@breathe-ds/ui/components/curtainHeadScript";
165
167
  <Lockup variant="horizontal" size="md" logo={<CreetaSymbol />} wordmark={<CreetaWordmark />} wordmarkRatio={CREETA_LOCKUP.wordmarkPerSymbol} name="creeta" />
166
168
  ```
167
169
 
@@ -306,6 +308,12 @@ from color, not from extra markup.
306
308
  the page-level pause contract (`CARD_VIDEOS_PAUSE_EVENT` / `CARD_VIDEOS_PAUSED_ATTR`).
307
309
  - **TestimonialCard**: clip with a play button on the left, quote + author +
308
310
  outbound link on the right ("Famílias contam").
311
+ **Rule (0.6.8): in a grid of testimonials with `panel`, the card is ONLY a quote**
312
+ — a short excerpt in the person's own words, large, with the drawn quote mark;
313
+ the whole testimonial (and its video or photo) opens in the panel on click. Pass
314
+ the `excerpt` the client chose (`excerptMarks={false}` for a context line that is
315
+ not the person's speech); without one, the card takes the first sentence.
316
+ Never show the long text clamped on the card.
309
317
  - **MapCard**: the Mapa of novo.creeta — a still preview fading into the card,
310
318
  the address, "View the map" and the outbound "Directions" link; Google is only
311
319
  called after the tap (no consent banner, lighter page). Open, the map fills
@@ -98,16 +98,11 @@ export function Curtain({ name, tagline, logo, wordmarkRatio, symbolSide = "left
98
98
  }
99
99
 
100
100
  /**
101
- * Script for the <head> (before paint): marks the curtain as seen in this
102
- * session, so it does not repeat on navigation or reload. Without it, the
103
- * curtain shows on every load.
104
- * // Next (app/layout):
105
- * <script dangerouslySetInnerHTML={{ __html: curtainHeadScript("cg-curtain") }} />
101
+ * Script for the <head>: moved to `components/curtainHeadScript.ts` (2026-11-05)
102
+ * so a server layout can call it (this file is "use client"). Kept here for old
103
+ * imports from client code.
106
104
  */
107
- export function curtainHeadScript(storageKey = "cg-curtain") {
108
- const k = JSON.stringify(storageKey);
109
- return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-curtain','seen')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
110
- }
105
+ export { curtainHeadScript } from "./curtainHeadScript";
111
106
 
112
107
  const CURTAIN_CSS = `
113
108
  .cg-curtain{
@@ -79,7 +79,11 @@ type Props = {
79
79
  * or only in the panel — the card stays a clean quote with the avatar. */
80
80
  mediaPlacement?: "card" | "panel";
81
81
  /** The card shows only this excerpt, large, as a pull quote (the person's own
82
- * words, never rewritten); the whole `quote` goes to the panel. */
82
+ * words, never rewritten); the whole `quote` goes to the panel.
83
+ * RULE (2026-11-05, Felipe): with `panel`, the card is ONLY a quote — never
84
+ * the long text clamped. Pass the excerpt the client chose; without one, the
85
+ * card takes the first sentence of a string `quote` (cut at a word, with "…",
86
+ * when it runs long). The whole testimonial always opens in the panel. */
83
87
  excerpt?: ReactNode;
84
88
  /** Quote marks around the excerpt. false for a context line that is not the
85
89
  * person's speech (e.g. the description of a video). Default: true. */
@@ -179,8 +183,21 @@ function TapVideo({ src, youtubeId, poster: posterProp, description, withSound =
179
183
  // whole class names, so Tailwind generates them
180
184
  const CLAMP: Record<number, string> = { 3: "line-clamp-3", 4: "line-clamp-4", 5: "line-clamp-5", 6: "line-clamp-6", 8: "line-clamp-8" };
181
185
 
186
+ /** First sentence of a testimonial, for the card when no excerpt was chosen.
187
+ * Long sentences are cut at a word (≤ 140 characters) with "…". */
188
+ export function firstSentence(text: string, max = 140): string {
189
+ const clean = text.replace(/\s+/g, " ").trim();
190
+ const m = /^(.+?[.!?])(\s|$)/.exec(clean);
191
+ const s = m ? m[1] : clean;
192
+ if (s.length <= max) return s;
193
+ const cut = s.slice(0, max);
194
+ return `${cut.slice(0, cut.lastIndexOf(" ")).replace(/[,;:\s]+$/, "")}…`;
195
+ }
196
+
182
197
  export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, authors, link, video, photo, mediaPlacement = "card", role, joiners, panel, excerpt, excerptMarks = true, clampLines, as: Root = "div", labels, className = "" }: Props) {
183
198
  const t = { ...EN, ...labels };
199
+ // with a panel the card is only a quote: the given excerpt, or the first sentence
200
+ const shownExcerpt = excerpt ?? (panel && typeof quote === "string" ? firstSentence(quote) : undefined);
184
201
  const watch = (d: string) => (typeof t.watch === "function" ? t.watch(d) : t.watch.replace("{description}", d));
185
202
  const people = authors ?? [{ name: author ?? "", photo: authorPhoto, avatar: authorAvatar }];
186
203
  const cardPhoto = mediaPlacement === "card" ? photo : undefined;
@@ -227,7 +244,7 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
227
244
  ) : null}
228
245
  <figure className={`flex flex-1 flex-col justify-between gap-5 ${cardVideo || cardPhoto ? "p-5 md:p-7" : "p-6 md:p-8"}`}>
229
246
  <div>
230
- {excerpt ? (
247
+ {shownExcerpt ? (
231
248
  <blockquote className="m-0">
232
249
  {/* the family's drawn mark (same as PullQuote) above the line; the line itself stays clean */}
233
250
  {excerptMarks && (
@@ -235,7 +252,7 @@ export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, auth
235
252
  <path d="M0 36V22C0 10.5 6 2.6 18.2 0l2.3 5.2C14.6 7.4 11.4 11 11 16h8.6v20H0Zm27.4 0V22c0-11.5 6-19.4 18.2-22l2.3 5.2C42 7.4 38.8 11 38.4 16H47v20H27.4Z" />
236
253
  </svg>
237
254
  )}
238
- <p className="m-0 font-heading text-[1.3rem] leading-snug font-semibold text-balance text-ink md:text-[1.5rem]">{excerpt}</p>
255
+ <p className="m-0 font-heading text-[1.3rem] leading-snug font-semibold text-balance text-ink md:text-[1.5rem]">{shownExcerpt}</p>
239
256
  </blockquote>
240
257
  ) : (
241
258
  <blockquote
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Script for the <head> (before paint) of the Curtain: marks the curtain as seen
3
+ * in this session, so it does not repeat on navigation or reload. Without it, the
4
+ * curtain shows on every load. Plain module (no "use client"): a server layout
5
+ * can call it.
6
+ * // Next (app/layout):
7
+ * import { curtainHeadScript } from "@breathe-ds/ui/components/curtainHeadScript";
8
+ * <script dangerouslySetInnerHTML={{ __html: curtainHeadScript("cg-curtain") }} />
9
+ */
10
+ export function curtainHeadScript(storageKey = "cg-curtain") {
11
+ const k = JSON.stringify(storageKey);
12
+ return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-curtain','seen')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breathe-ds/ui",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Breathe DS — the design system of the Cuidado Global family of sites (creeta, ttcab, metodorta). Themeable tokens + React components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "./components/BottomNav": "./components/BottomNav.tsx",
51
51
  "./components/BreatheMark": "./components/BreatheMark.tsx",
52
52
  "./components/Curtain": "./components/Curtain.tsx",
53
+ "./components/curtainHeadScript": "./components/curtainHeadScript.ts",
53
54
  "./components/Lockup": "./components/Lockup.tsx",
54
55
  "./tokens/neutral.css": {
55
56
  "style": "./tokens/neutral.css",