@signal9/era-ui 4.13.0 → 4.14.1

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.
@@ -27,6 +27,6 @@
27
27
  >
28
28
  {@render icon?.()}
29
29
  <p class="text-body font-medium text-bright">{title}</p>
30
- <p class="max-w-sm text-body text-muted">{description}</p>
30
+ <p class="max-w-sm text-body leading-body text-muted">{description}</p>
31
31
  {@render children?.()}
32
32
  </div>
@@ -88,7 +88,7 @@
88
88
  {/if}
89
89
 
90
90
  {#if guidance}
91
- <p class="text-body text-muted">{guidance}</p>
91
+ <p class="text-body leading-body text-muted">{guidance}</p>
92
92
  {/if}
93
93
 
94
94
  {#if showDetail}
@@ -16,7 +16,7 @@
16
16
  tier → rd-md (the step-content / menu-panel precedent), not rd-lg. -->
17
17
  <div
18
18
  class={cn(
19
- 'min-w-0 text-body leading-relaxed text-fg',
19
+ 'min-w-0 text-body leading-body text-fg',
20
20
  'group-data-[role=user]/message:max-w-[80%] group-data-[role=user]/message:rounded-(--era-rd-md) group-data-[role=user]/message:bg-(--era-surface-bg-elevated) group-data-[role=user]/message:p-(--era-pad-sm) group-data-[role=user]/message:whitespace-pre-wrap group-data-[role=user]/message:shadow-(--era-shadow)',
21
21
  className
22
22
  )}
@@ -58,7 +58,7 @@
58
58
  onkeydown={onKeydown}
59
59
  onpaste={onPaste}
60
60
  class={cn(
61
- 'field-sizing-content max-h-48 min-h-(--era-h-md) w-full resize-none bg-transparent px-(--era-inset-md) py-[calc((var(--era-h-md)-1lh)/2)] text-body leading-relaxed text-fg outline-none placeholder:text-muted',
61
+ 'field-sizing-content max-h-48 min-h-(--era-h-md) w-full resize-none bg-transparent px-(--era-inset-md) py-[calc((var(--era-h-md)-1lh)/2)] text-body leading-body text-fg outline-none placeholder:text-muted',
62
62
  className
63
63
  )}
64
64
  {...restProps}
@@ -34,7 +34,7 @@
34
34
  <!-- eslint-disable better-tailwindcss/no-unknown-classes -- era-response* are defined in this component's <style> -->
35
35
  <div
36
36
  class={cn(
37
- 'era-response flex min-w-0 flex-col gap-(--era-gap) text-body leading-relaxed text-fg',
37
+ 'era-response flex min-w-0 flex-col gap-(--era-gap) text-body leading-body text-fg',
38
38
  streaming && 'era-response-streaming',
39
39
  className
40
40
  )}
@@ -37,5 +37,5 @@
37
37
  <span class="shrink-0 font-mono text-body text-muted tabular-nums">{age}</span>
38
38
  {/if}
39
39
  </div>
40
- <p class="text-body leading-relaxed text-muted">{artifact.summary}</p>
40
+ <p class="text-body leading-body text-muted">{artifact.summary}</p>
41
41
  </Card>
@@ -12,7 +12,7 @@
12
12
  {:else if output !== undefined}
13
13
  <Step.Section label="output">
14
14
  {#if typeof output === 'string'}
15
- <p class="text-body whitespace-pre-wrap text-muted">{output}</p>
15
+ <p class="text-body leading-body whitespace-pre-wrap text-muted">{output}</p>
16
16
  {:else}
17
17
  <CodeBlock code={JSON.stringify(output, null, 2)} />
18
18
  {/if}
@@ -223,7 +223,7 @@
223
223
  <AI.Reasoning.Root streaming={block.streaming} bind:duration={block.duration}>
224
224
  <AI.Reasoning.Trigger streaming={block.streaming} duration={block.duration} />
225
225
  <AI.Reasoning.Content>
226
- <p class="text-body leading-relaxed text-muted">{block.text}</p>
226
+ <p class="text-body leading-body text-muted">{block.text}</p>
227
227
  </AI.Reasoning.Content>
228
228
  </AI.Reasoning.Root>
229
229
  {:else if block.kind === 'tool'}
@@ -6,9 +6,10 @@ import { iconFlush } from './icon-flush.js';
6
6
  import { offAxisMotion } from './off-axis-motion.js';
7
7
  import { offTierHeight } from './off-tier-height.js';
8
8
  import { pillHoldsItsInk } from './pill-holds-its-ink.js';
9
+ import { proseLeading } from './prose-leading.js';
9
10
  import { radiusConcentricity } from './radius-concentricity.js';
10
11
  import { restingGap } from './resting-gap.js';
11
12
  import { singleGlyphSquare } from './single-glyph-square.js';
12
13
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
13
14
  import { wallMatchesGap } from './wall-matches-gap.js';
14
- export { clippedGlyphs, collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, pillHoldsItsInk, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
15
+ export { clippedGlyphs, collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
@@ -7,12 +7,13 @@ import { iconFlush } from './icon-flush.js';
7
7
  import { offAxisMotion } from './off-axis-motion.js';
8
8
  import { offTierHeight } from './off-tier-height.js';
9
9
  import { pillHoldsItsInk } from './pill-holds-its-ink.js';
10
+ import { proseLeading } from './prose-leading.js';
10
11
  import { radiusConcentricity } from './radius-concentricity.js';
11
12
  import { restingGap } from './resting-gap.js';
12
13
  import { singleGlyphSquare } from './single-glyph-square.js';
13
14
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
14
15
  import { wallMatchesGap } from './wall-matches-gap.js';
15
- export { clippedGlyphs, collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, pillHoldsItsInk, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
16
+ export { clippedGlyphs, collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
16
17
  // Auto-register built-ins. Consumers can still unregister or add their own.
17
18
  //
18
19
  // Two of these only tell the truth on a particular axis setting, because the bug
@@ -29,6 +30,7 @@ registry.register(iconFlush);
29
30
  registry.register(offAxisMotion);
30
31
  registry.register(offTierHeight);
31
32
  registry.register(pillHoldsItsInk);
33
+ registry.register(proseLeading);
32
34
  registry.register(radiusConcentricity);
33
35
  registry.register(restingGap);
34
36
  registry.register(singleGlyphSquare);
@@ -0,0 +1,2 @@
1
+ import type { Audit } from '../types.js';
2
+ export declare const proseLeading: Audit;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Wrapping text set at a control's line-height.
3
+ *
4
+ * Era's type has two regimes and they want opposite things:
5
+ *
6
+ * SINGLE-LINE control text wants NO leading. `era-text-trim` collapses the
7
+ * line box to 1 and centres the ink inside a tier height — the space above
8
+ * and below the line is the container's job, not the type's.
9
+ *
10
+ * WRAPPING text wants leading and nothing else provides it. The space between
11
+ * line 1 and line 2 is the only thing holding a paragraph apart, so a
12
+ * paragraph set at line-height 1 is not "tight", it is two lines touching.
13
+ *
14
+ * The failure is silent and it is INHERITED, which is why it needs a runtime
15
+ * check rather than a lint rule: a wrapping <p> that names no leading of its own
16
+ * takes whatever its ancestors set, and in this library ancestors are very often
17
+ * fixed-height rows that legitimately set `leading-none` or the trim. The <p>
18
+ * looks correct in the markup — it just renders wrong the moment its text is
19
+ * long enough to wrap, which is exactly the case nobody screenshots.
20
+ *
21
+ * So the check is geometric, on live text: find elements that ACTUALLY wrapped
22
+ * (≥2 line boxes measured off a Range over their own text), then assert their
23
+ * line-height is a prose ratio and not a control's. Single-line text is never
24
+ * flagged — a trimmed button label is correct and must stay correct.
25
+ *
26
+ * The fix is always the same: add `leading-body` (--era-leading, 1.6).
27
+ */
28
+ /** Below this ratio, the leading belongs to a control, not to a paragraph. */
29
+ const PROSE_RATIO = 1.35;
30
+ /**
31
+ * How many line boxes this element's OWN text occupies.
32
+ *
33
+ * Measured with a Range over the element's direct text nodes rather than by
34
+ * dividing height by line-height: the element may also contain inline children,
35
+ * floats or a wrapped icon, any of which make the height lie. Rect tops are
36
+ * bucketed to the nearest 0.5px because a fractional line box can report
37
+ * neighbouring rects a hair apart.
38
+ */
39
+ function ownTextLines(el) {
40
+ const range = document.createRange();
41
+ const tops = new Set();
42
+ for (const node of el.childNodes) {
43
+ if (node.nodeType !== Node.TEXT_NODE || !node.textContent?.trim())
44
+ continue;
45
+ range.selectNodeContents(node);
46
+ for (const rect of range.getClientRects()) {
47
+ if (rect.width === 0 && rect.height === 0)
48
+ continue;
49
+ tops.add(Math.round(rect.top * 2) / 2);
50
+ }
51
+ }
52
+ range.detach();
53
+ return tops.size;
54
+ }
55
+ export const proseLeading = {
56
+ id: 'typography/prose-leading',
57
+ name: 'Wrapping text has prose leading',
58
+ description: 'Text that wraps to a second line must set its own leading (leading-body) — otherwise it inherits a fixed-height row’s control leading and the lines collide.',
59
+ category: 'layout',
60
+ severity: 'error',
61
+ // Only elements that can hold text directly; a <div> wrapper with no text of
62
+ // its own has nothing to measure and is skipped by ownTextLines anyway.
63
+ selector: '*',
64
+ check(el) {
65
+ if (!el.textContent?.trim())
66
+ return null;
67
+ const s = getComputedStyle(el);
68
+ // Text that cannot wrap cannot have this bug — the whole defect is the
69
+ // gap between line 1 and line 2.
70
+ if (s.whiteSpace === 'nowrap' || s.whiteSpace === 'pre')
71
+ return null;
72
+ const fontSize = parseFloat(s.fontSize);
73
+ const lineHeight = parseFloat(s.lineHeight); // `normal` resolves to px here
74
+ if (!fontSize || !lineHeight)
75
+ return null;
76
+ const ratio = lineHeight / fontSize;
77
+ if (ratio >= PROSE_RATIO)
78
+ return null;
79
+ // The expensive half, deliberately last: only measure once the leading is
80
+ // already known to be too tight.
81
+ if (ownTextLines(el) < 2)
82
+ return null;
83
+ const issue = {
84
+ auditId: 'typography/prose-leading',
85
+ element: el,
86
+ message: `wrapped text at line-height ${ratio.toFixed(2)}× (${lineHeight.toFixed(1)}px on ${fontSize}px type) — this is control leading, inherited from a fixed-height row. Add leading-body.`,
87
+ details: { ratio: +ratio.toFixed(2), lineHeight, fontSize, lines: ownTextLines(el) }
88
+ };
89
+ return issue;
90
+ }
91
+ };