@crtobiasdelsud/portal-ui 1.1.2 → 1.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crtobiasdelsud/portal-ui",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Componentes compartidos entre el portal (Next) y el CMS (Vite) — widgets, views, providers para adapters y article pool.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -57,7 +57,9 @@ function loadTwitterWidgets() {
57
57
 
58
58
  function EmbedIframe({ src, service, style }) {
59
59
  const safeSrc = sanitizeResourceUrl(src)
60
- const ref = useRef(null)
60
+ const wrapRef = useRef(null) // placeholder observado por IO
61
+ const contentRef = useRef(null) // contenedor real del tweet/iframe (post-inView)
62
+ const [inView, setInView] = useState(false)
61
63
  const [autoHeight, setAutoHeight] = useState(null)
62
64
  const [twitterDone, setTwitterDone] = useState(false)
63
65
  if (!safeSrc) return null
@@ -68,10 +70,32 @@ function EmbedIframe({ src, service, style }) {
68
70
  // solo → altura exacta, sin blanco ni scroll.
69
71
  const tweetId = service === 'twitter' ? (String(src).match(/[?&]id=(\d+)/)?.[1] || null) : null
70
72
 
73
+ // Lazy load: difiere TODO (iframe src, widgets.js de Twitter, listener de
74
+ // Instagram MEASURE) hasta que el embed esté cerca del viewport. Sin esto,
75
+ // tweets / instagram / youtube debajo del fold cargan junto con la nota y
76
+ // pegan al LCP. El rootMargin de 400px precarga antes que el usuario llegue.
71
77
  useEffect(() => {
72
- if (!tweetId) return
78
+ if (inView) return
79
+ if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
80
+ const el = wrapRef.current
81
+ if (!el) return
82
+ const io = new IntersectionObserver(
83
+ (entries) => {
84
+ if (entries.some(e => e.isIntersecting)) {
85
+ setInView(true)
86
+ io.disconnect()
87
+ }
88
+ },
89
+ { rootMargin: '400px 0px' },
90
+ )
91
+ io.observe(el)
92
+ return () => io.disconnect()
93
+ }, [inView])
94
+
95
+ useEffect(() => {
96
+ if (!inView || !tweetId) return
73
97
  let cancelled = false
74
- const el = ref.current
98
+ const el = contentRef.current
75
99
  if (!el) return
76
100
  loadTwitterWidgets().then((twttr) => {
77
101
  if (cancelled || !twttr?.widgets || !el) return
@@ -81,16 +105,16 @@ function EmbedIframe({ src, service, style }) {
81
105
  .then(() => { if (!cancelled) setTwitterDone(true) })
82
106
  })
83
107
  return () => { cancelled = true }
84
- }, [tweetId])
108
+ }, [inView, tweetId])
85
109
 
86
110
  useEffect(() => {
87
111
  // Servicios que se sabe que postean MEASURE: Instagram. (Twitter se maneja
88
112
  // arriba con widgets.js. TikTok no postea altura.)
89
- if (service !== 'instagram') return
113
+ if (!inView || service !== 'instagram') return
90
114
 
91
115
  function handleMessage(event) {
92
116
  if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
93
- if (event.source !== ref.current?.contentWindow) return
117
+ if (event.source !== contentRef.current?.contentWindow) return
94
118
 
95
119
  let data = event.data
96
120
  if (typeof data === 'string') {
@@ -102,18 +126,24 @@ function EmbedIframe({ src, service, style }) {
102
126
 
103
127
  window.addEventListener('message', handleMessage)
104
128
  return () => window.removeEventListener('message', handleMessage)
105
- }, [service])
129
+ }, [inView, service])
130
+
131
+ // Placeholder antes de inView: ocupa el lugar (height del callsite) pero no
132
+ // dispara ningún fetch. Reserva el espacio → no genera CLS al "hidratar".
133
+ if (!inView) {
134
+ return <div ref={wrapRef} style={style} aria-hidden="true" />
135
+ }
106
136
 
107
137
  // Twitter via widget: contenedor que crece con el tweet. `minHeight` es solo
108
138
  // placeholder anti-CLS hasta que widgets.js mida; después el widget fija su
109
139
  // propia altura exacta. Si no hubo tweetId (URL rara), cae al iframe normal.
110
140
  if (tweetId) {
111
141
  const { height, ...rest } = style || {}
112
- return <div ref={ref} style={{ ...rest, minHeight: twitterDone ? undefined : height }} />
142
+ return <div ref={contentRef} style={{ ...rest, minHeight: twitterDone ? undefined : height }} />
113
143
  }
114
144
 
115
145
  const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
116
- return <iframe ref={ref} src={safeSrc} style={finalStyle} allowFullScreen />
146
+ return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
117
147
  }
118
148
 
119
149
  // Fixed class names for AMP (no CSS Modules hashing)
@@ -38,16 +38,39 @@ function resolveEmbedSrc({ service, embed, source }) {
38
38
 
39
39
  function EmbedIframe({ src, service, style }) {
40
40
  const safeSrc = sanitizeResourceUrl(src)
41
- const ref = useRef(null)
41
+ const wrapRef = useRef(null) // placeholder observado por IO
42
+ const contentRef = useRef(null) // iframe real (post-inView)
43
+ const [inView, setInView] = useState(false)
42
44
  const [autoHeight, setAutoHeight] = useState(null)
43
45
  if (!safeSrc) return null
44
46
 
47
+ // Lazy load: difiere el iframe src y el listener de Instagram hasta que el
48
+ // embed esté cerca del viewport (400px de margen para precargar). Sin esto
49
+ // los embeds abajo del fold cargan junto con la nota y suben el LCP.
45
50
  useEffect(() => {
46
- if (service !== 'instagram') return
51
+ if (inView) return
52
+ if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
53
+ const el = wrapRef.current
54
+ if (!el) return
55
+ const io = new IntersectionObserver(
56
+ (entries) => {
57
+ if (entries.some(e => e.isIntersecting)) {
58
+ setInView(true)
59
+ io.disconnect()
60
+ }
61
+ },
62
+ { rootMargin: '400px 0px' },
63
+ )
64
+ io.observe(el)
65
+ return () => io.disconnect()
66
+ }, [inView])
67
+
68
+ useEffect(() => {
69
+ if (!inView || service !== 'instagram') return
47
70
 
48
71
  function handleMessage(event) {
49
72
  if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
50
- if (event.source !== ref.current?.contentWindow) return
73
+ if (event.source !== contentRef.current?.contentWindow) return
51
74
 
52
75
  let data = event.data
53
76
  if (typeof data === 'string') {
@@ -59,10 +82,15 @@ function EmbedIframe({ src, service, style }) {
59
82
 
60
83
  window.addEventListener('message', handleMessage)
61
84
  return () => window.removeEventListener('message', handleMessage)
62
- }, [service])
85
+ }, [inView, service])
86
+
87
+ // Placeholder antes de inView: ocupa el lugar pero no dispara fetch del iframe.
88
+ if (!inView) {
89
+ return <div ref={wrapRef} style={style} aria-hidden="true" />
90
+ }
63
91
 
64
92
  const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
65
- return <iframe ref={ref} src={safeSrc} style={finalStyle} allowFullScreen />
93
+ return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
66
94
  }
67
95
 
68
96
  // Fixed class names for AMP (no CSS Modules hashing)
@@ -69,6 +69,11 @@
69
69
  // inline (color + peso) DENTRO de la misma caja, así no se ve corte entre
70
70
  // las dos partes del texto.
71
71
  .headline {
72
+ // INLINE a propósito: box-decoration-break: clone solo parte el fondo por
73
+ // renglón en cajas inline. En un h2 block-level el background es UN rectángulo
74
+ // macizo que cubre todas las líneas (el "bloque horrible"). Inline + clone hace
75
+ // que cada renglón sea su propia caja blanca ajustada al texto (efecto resaltador).
76
+ display: inline;
72
77
  -webkit-box-decoration-break: clone;
73
78
  box-decoration-break: clone;
74
79
  background: #fff;
@@ -77,7 +82,11 @@
77
82
  color: var(--text-color, #252523);
78
83
  font-size: 20px;
79
84
  font-weight: 800;
80
- line-height: 1.55;
85
+ // Cada renglón es una caja blanca clonada (box-decoration-break). El hueco
86
+ // entre renglones es line-height − altura_de_fuente − 2·padding(3px). Con 1.55
87
+ // ese hueco caía a ~2px y las cajas se tocaban según el título; 1.9 garantiza
88
+ // ~9px parejo. Tablet/desktop ya tienen aire suficiente con 1.7.
89
+ line-height: 1.9;
81
90
 
82
91
  @include respond(tablet) {
83
92
  font-size: 30px;
@@ -127,15 +136,21 @@
127
136
  // ── Hero importante ──────────────────────────────────────────────────────
128
137
  .important {
129
138
  .mediaBox {
139
+ // aspect 16/9 (NO 16/7): con object-fit cover, una caja más ancha que la
140
+ // imagen (16:9) recorta arriba/abajo y corta la cabeza del sujeto. 16/9 iguala
141
+ // el ratio típico de la foto → no recorta en vertical y la imagen "ocupa más".
130
142
  @include respond(tablet) {
131
- aspect-ratio: 16 / 7;
143
+ aspect-ratio: 16 / 9;
132
144
  }
133
- // Desktop: V1 cap la imagen importante a 500px (sin esto el hero ocupa
134
- // ~774px y rompe el ratio respecto a V1). Replicamos acá para que V2 y
135
- // V1 tengan la misma altura en portada de escritorio.
145
+ // Desktop: el hero importante va a todo el ancho (banner), sin un hermano
146
+ // flex que defina el alto de la fila el `height: 100%` heredado del base
147
+ // colapsa a 0 y la imagen (AspectImage en modo `fill`, absolute) desaparece.
148
+ // Le damos alto propio derivado del ancho con aspect-ratio 16/9; el tope alto
149
+ // (760px) sólo recorta en pantallas muy anchas para no desbordar la pantalla.
136
150
  @include respond(desktop) {
137
- aspect-ratio: unset;
138
- max-height: 500px;
151
+ height: auto;
152
+ aspect-ratio: 16 / 9;
153
+ max-height: 760px;
139
154
  }
140
155
  }
141
156
 
@@ -82,19 +82,12 @@
82
82
  // uppercase. El tamaño chico la hace funcionar como "etiqueta de sección".
83
83
  .volanta {
84
84
  color: var(--primary-color, #4bac48);
85
- font-size: 14px;
85
+ // Mismo tamaño que el título (como en V2): antes tenía font-size propio chico
86
+ // (14/18/20) y quedaba diminuta al lado del headline de 32px. Hereda del h2.
87
+ font-size: inherit;
86
88
  font-weight: 700;
87
89
  text-transform: uppercase;
88
90
  letter-spacing: 0.04em;
89
- white-space: nowrap;
90
-
91
- @include respond(tablet) {
92
- font-size: 18px;
93
- }
94
-
95
- @include respond(desktop) {
96
- font-size: 20px;
97
- }
98
91
  }
99
92
 
100
93
  .headline {
@@ -147,15 +140,21 @@
147
140
  // ── Hero importante ──────────────────────────────────────────────────────
148
141
  .important {
149
142
  .mediaBox {
143
+ // aspect 16/9 (NO 16/7): con object-fit cover, una caja más ancha que la
144
+ // imagen (16:9) recorta arriba/abajo y corta la cabeza del sujeto. 16/9 iguala
145
+ // el ratio típico de la foto → no recorta en vertical y la imagen "ocupa más".
150
146
  @include respond(tablet) {
151
- aspect-ratio: 16 / 7;
147
+ aspect-ratio: 16 / 9;
152
148
  }
153
- // Desktop: V1 cap la imagen importante a 500px (sin esto el hero ocupa
154
- // ~774px y rompe el ratio respecto a V1). Replicamos acá para que V3 y
155
- // V1 tengan la misma altura en portada de escritorio.
149
+ // Desktop: el hero importante va a todo el ancho (banner), sin un hermano
150
+ // flex que defina el alto de la fila el `height: 100%` heredado del base
151
+ // colapsa a 0 y la imagen (AspectImage en modo `fill`, absolute) desaparece.
152
+ // Le damos alto propio derivado del ancho con aspect-ratio 16/9; el tope alto
153
+ // (760px) sólo recorta en pantallas muy anchas para no desbordar la pantalla.
156
154
  @include respond(desktop) {
157
- aspect-ratio: unset;
158
- max-height: 500px;
155
+ height: auto;
156
+ aspect-ratio: 16 / 9;
157
+ max-height: 760px;
159
158
  }
160
159
  }
161
160
 
@@ -1,12 +1,21 @@
1
- // Helper compartido por cabezales, hero, bajada, etc. La volanta se renderiza
2
- // inline con el título separada por ". " (en cabezales el espacio lo da el
3
- // `margin-right` del CSS; en bajada/leeAdemas/loQueSeLee es un literal en JSX).
4
- // Si quien edita la nota ya cerró la volanta con un signo (".", "!", "?", "…"),
5
- // no agregamos otro punto — devolvemos el texto tal cual.
1
+ // Helper compartido por cabezales, hero, bajada, etc. Normaliza la volanta
2
+ // para que el render con el título siempre sea: «volanta + punto + espacio + titulo».
3
+ // El espacio entre volanta y titulo lo provee el callsite (literal " " en JSX,
4
+ // o `margin-right` en CSS).
5
+ //
6
+ // Normalización:
7
+ // 1. Quita whitespace trailing ("postgres " → "postgres").
8
+ // 2. Colapsa whitespace entre la palabra y un signo final ("postgres ." → "postgres.").
9
+ // 3. Si no termina en signo de cierre (.!?…), agrega ".".
10
+ //
11
+ // Resultado: "Política." / "Política." (sin doble) / "EXCLUSIVO!" / "" .
6
12
 
7
- const SENTENCE_END = /[.!?…]\s*$/
13
+ const SENTENCE_END = /[.!?…]$/
8
14
 
9
15
  export function volantaWithStop(volanta) {
10
16
  if (!volanta) return ''
11
- return SENTENCE_END.test(volanta) ? volanta : `${volanta}.`
17
+ // Colapsa "palabra <space> . <space>" "palabra.", y limpia trailing.
18
+ const clean = volanta.replace(/\s+([.!?…])\s*$/, '$1').trimEnd()
19
+ if (!clean) return ''
20
+ return SENTENCE_END.test(clean) ? clean : `${clean}.`
12
21
  }