@signal9/era-ui 2.26.0 → 2.27.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.
@@ -2,37 +2,65 @@
2
2
  * The chopped-text bug.
3
3
  *
4
4
  * `era-text-trim` centres the ink by trimming the line box down to the x-height
5
- * band (text-box: trim-both ex alphabetic). That is exactly right on a control
6
- * with a FIXED height — the tier holds the box open and the trim only re-centres
7
- * what is inside it.
5
+ * band (text-box: trim-both ex alphabetic). It has two failure modes, and they
6
+ * are NOT the same check — the first one is why the Input shipped with every
7
+ * ascender sliced off the text you typed while this audit sat here reporting
8
+ * nothing.
8
9
  *
9
- * Put the same utility on an AUTO-height element that also clips its overflow
10
- * (`truncate`, `overflow-hidden`, an <input>) and the box collapses to the
11
- * ~7.5px x-height band. The clip then slices every ascender, cap and descender
12
- * off the glyphs, and the text renders as a chopped middle stripe.
10
+ * 1. NATIVE EDITABLES (<input>, <textarea>, <select>).
11
+ * A fixed height does NOT make these safe. The UA lays out an inner editor
12
+ * box and clips to IT, and that inner box is the trimmed line box — so the
13
+ * trim collapses it to the x-height band no matter how tall the control is,
14
+ * and the glyphs lose their tops and tails ("d" renders as "o"). The
15
+ * element's own height therefore says nothing about whether it is broken:
16
+ * the trim being ACTIVE is the whole defect. These elements also hold their
17
+ * text in `value`, not in textContent, which is the other reason the old
18
+ * height-and-textContent check could never fire on them.
13
19
  *
14
- * The tell is unmistakable in the geometry: a box shorter than its own font-size
15
- * that is still clipping. No static check can see it whether the box collapses
16
- * depends on whether some OTHER class set a height.
20
+ * 2. AUTO-HEIGHT CLIPPING ELEMENTS (a `truncate` child, overflow-hidden with no
21
+ * height). Nothing holds the box open, so it collapses to the ~7.5px band and
22
+ * the clip chops the glyphs into a middle stripe. Here the tell IS geometric:
23
+ * a box shorter than its own font-size that is still clipping. A FIXED-height
24
+ * non-editable box is fine — the ink overflows the trimmed LINE box but stays
25
+ * inside the padding box, which is what clips.
17
26
  */
27
+ const EDITABLE = 'input, textarea, select';
28
+ /** The trim, as the browser actually resolved it. Chromium exposes the longhand. */
29
+ function activeTrim(s) {
30
+ const trim = s.getPropertyValue('text-box-trim').trim() || s.getPropertyValue('text-box').trim();
31
+ if (!trim || trim === 'none' || trim === 'normal')
32
+ return null;
33
+ return trim;
34
+ }
18
35
  export const collapsedTextTrim = {
19
36
  id: 'typography/collapsed-text-trim',
20
37
  name: 'Trimmed text box has not collapsed',
21
- description: 'era-text-trim on an auto-height element that clips overflow collapses the box to the x-height band, slicing the tops and tails off the glyphs.',
38
+ description: 'era-text-trim clips the glyphs when it lands on a native editable (any height) or on an auto-height element that clips its overflow.',
22
39
  category: 'layout',
23
40
  severity: 'error',
24
41
  selector: '*',
25
42
  check(el) {
26
43
  const s = getComputedStyle(el);
27
- // Is the trim active on this element? Chromium exposes the longhand.
28
- const trim = s.getPropertyValue('text-box-trim').trim() || s.getPropertyValue('text-box').trim();
29
- if (!trim || trim === 'none' || trim === 'normal')
44
+ const trim = activeTrim(s);
45
+ if (!trim)
30
46
  return null;
31
- // Is it clipping?
47
+ // (1) A native editable is broken by the trim at ANY height — it clips to the
48
+ // trimmed inner editor box. No geometry test, and no textContent test: an
49
+ // input's text lives in `value`, and an EMPTY input is just as broken, it
50
+ // simply has nothing to chop yet.
51
+ if (el.matches(EDITABLE)) {
52
+ const issue = {
53
+ auditId: 'typography/collapsed-text-trim',
54
+ element: el,
55
+ message: `text-box trim (${trim}) on a <${el.tagName.toLowerCase()}> — a native editable clips to the trimmed inner editor box, so the text you type loses its ascenders and descenders ("d" renders as "o"). Drop the trim; the tier height centres it.`,
56
+ details: { trim, tag: el.tagName.toLowerCase() }
57
+ };
58
+ return issue;
59
+ }
60
+ // (2) Everything else: only a collapsed, clipping box is a defect.
32
61
  const clips = /hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY);
33
62
  if (!clips)
34
63
  return null;
35
- // Does it hold any text of its own?
36
64
  if (!el.textContent?.trim())
37
65
  return null;
38
66
  const h = el.getBoundingClientRect().height;
@@ -15,4 +15,5 @@
15
15
 
16
16
  - Heights, radii, paddings, and gaps come from density tokens — never hard-code px.
17
17
  - `link` variant intentionally does not change color on hover (underline only) so it reads as static prose until confirmed interactive.
18
+ - `variant="link"` is for a _control_ that should look like a link (a button that reveals a panel, a destructive action in a row). For a link in body copy or prose, use the `era-link` CSS utility on an `<a>` instead — see [Utilities](/utilities.md).
18
19
  - For a segmented row of buttons, use `ButtonGroup`.
@@ -0,0 +1,21 @@
1
+ export interface CssUtility {
2
+ /** Class name, e.g. `era-link`. */
3
+ name: string;
4
+ /** Stylesheet the utility is declared in, relative to src/lib/styles. */
5
+ file: string;
6
+ /** One-line use case from the comment's `@use` line, if present. */
7
+ useCase?: string;
8
+ /** The rest of the doc comment — rationale, caveats, axis behaviour. */
9
+ description: string;
10
+ /** The `@utility` block verbatim, as authored. */
11
+ css: string;
12
+ }
13
+ /**
14
+ * @param sources stylesheet text keyed by path relative to src/lib/styles
15
+ * (e.g. `index.css`, `surfaces/glass.css`).
16
+ */
17
+ export declare function parseUtilities(sources: Record<string, string>): CssUtility[];
18
+ /** Renders the utilities reference — the generated `utilities.md` body. */
19
+ export declare function buildUtilitiesDoc(utilities: CssUtility[]): string;
20
+ /** One line per utility for llms.txt — name plus its searchable use case. */
21
+ export declare function buildUtilitiesIndex(utilities: CssUtility[]): string[];
@@ -0,0 +1,124 @@
1
+ /*
2
+ * Extracts era's Tailwind v4 `@utility` classes — era-link, era-text-trim,
3
+ * era-shimmer, scrollbar-none, glass-blur, … — from the stylesheet source.
4
+ *
5
+ * These are first-class API but they have no JS export and no .svelte file, so
6
+ * nothing else in the doc pipeline can see them: the component barrel misses
7
+ * them, the registry misses them, and a consumer (or an agent) reasoning from
8
+ * "what does era expose for links?" never finds `era-link`. The doc comment
9
+ * above each `@utility` is already thorough, so it — not a hand-maintained list
10
+ * — is the source of truth. This parses it.
11
+ *
12
+ * The convention the parser leans on: an `@use ` line in the comment is the
13
+ * one-line use case (what an agent searches), and the remaining prose is the
14
+ * long description. A utility with no `@use` line still documents fine; it just
15
+ * lands in the docs without a use-case summary.
16
+ *
17
+ * Pure (takes sources, returns data) so both the runtime glob loader and the
18
+ * node build script can drive it — same split as component-source-scanners.ts.
19
+ */
20
+ /** Files whose utilities are library API. Order fixes the doc's order. */
21
+ const FILE_ORDER = ['index.css', 'surfaces/glass.css'];
22
+ function cleanComment(raw) {
23
+ return raw
24
+ .split('\n')
25
+ .map((line) => line.replace(/^\s*\*\s?/, '').trimEnd())
26
+ .join('\n')
27
+ .trim();
28
+ }
29
+ /** Split the comment into its `@use` one-liner and the surrounding prose. */
30
+ function splitUseCase(comment) {
31
+ const lines = comment.split('\n');
32
+ const start = lines.findIndex((line) => line.startsWith('@use '));
33
+ if (start === -1)
34
+ return { description: comment.trim() };
35
+ let end = start + 1;
36
+ while (end < lines.length && lines[end].trim() !== '')
37
+ end++;
38
+ const useCase = lines
39
+ .slice(start, end)
40
+ .join(' ')
41
+ .replace(/^@use\s+/, '')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ const description = [...lines.slice(0, start), ...lines.slice(end)].join('\n').trim();
45
+ return { useCase, description };
46
+ }
47
+ /** Walk from the opening brace to its match so nested rules survive intact. */
48
+ function blockEnd(source, openBrace) {
49
+ let depth = 0;
50
+ for (let i = openBrace; i < source.length; i++) {
51
+ if (source[i] === '{')
52
+ depth++;
53
+ else if (source[i] === '}' && --depth === 0)
54
+ return i;
55
+ }
56
+ return source.length - 1;
57
+ }
58
+ function parseFile(file, source) {
59
+ const out = [];
60
+ // The comment body may not contain a terminator — a lazy `[\s\S]*?` would
61
+ // happily start at the first comment in the file and run through every `*/`
62
+ // between there and the @utility.
63
+ const pattern = /\/\*((?:(?!\*\/)[\s\S])*)\*\/\s*@utility\s+([\w-]+)\s*\{/g;
64
+ for (const match of source.matchAll(pattern)) {
65
+ const openBrace = match.index + match[0].length - 1;
66
+ const end = blockEnd(source, openBrace);
67
+ const { useCase, description } = splitUseCase(cleanComment(match[1]));
68
+ out.push({
69
+ name: match[2],
70
+ file,
71
+ useCase,
72
+ description,
73
+ css: source.slice(source.indexOf('@utility', match.index), end + 1)
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * @param sources stylesheet text keyed by path relative to src/lib/styles
80
+ * (e.g. `index.css`, `surfaces/glass.css`).
81
+ */
82
+ export function parseUtilities(sources) {
83
+ const rank = (file) => {
84
+ const i = FILE_ORDER.indexOf(file);
85
+ return i === -1 ? FILE_ORDER.length : i;
86
+ };
87
+ return Object.entries(sources)
88
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
89
+ .flatMap(([file, source]) => parseFile(file, source));
90
+ }
91
+ const INTRO = [
92
+ 'era ships a handful of Tailwind v4 `@utility` classes for the patterns that are',
93
+ 'styling, not components — an inline link, ink-centred control text, a hidden',
94
+ 'scrollbar. They need no import: they ride along with the stylesheet.',
95
+ '',
96
+ '```ts',
97
+ 'import "@sig-nine/era-ui/css";',
98
+ '```',
99
+ '',
100
+ '```svelte',
101
+ '<a class="era-link" href="/spacing">the spacing ladder</a>',
102
+ '```'
103
+ ].join('\n');
104
+ /** Renders the utilities reference — the generated `utilities.md` body. */
105
+ export function buildUtilitiesDoc(utilities) {
106
+ const lines = ['## Overview', '', INTRO, '', '| Utility | Use it for |', '|---|---|'];
107
+ for (const u of utilities) {
108
+ lines.push(`| \`${u.name}\` | ${u.useCase ?? '—'} |`);
109
+ }
110
+ lines.push('');
111
+ for (const u of utilities) {
112
+ lines.push(`## ${u.name}`, '');
113
+ if (u.useCase)
114
+ lines.push(`**Use it for:** ${u.useCase}`, '');
115
+ if (u.description)
116
+ lines.push(u.description, '');
117
+ lines.push(`Declared in \`${u.file}\`.`, '', '```css', u.css, '```', '');
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+ /** One line per utility for llms.txt — name plus its searchable use case. */
122
+ export function buildUtilitiesIndex(utilities) {
123
+ return utilities.map((u) => `- \`${u.name}\` — ${u.useCase ?? u.description.split('\n')[0]}`);
124
+ }
@@ -1,4 +1,6 @@
1
1
  export declare const docsBySlug: Record<string, string>;
2
2
  export declare const knownSlugs: Set<string>;
3
+ /** Served verbatim at /utilities.json so tooling can enumerate the CSS API. */
4
+ export declare const utilitiesJson: string;
3
5
  export declare const llmsTxtTemplate: string;
4
6
  export declare const llmsFullTxt: string;
@@ -12,6 +12,11 @@ const manifestModules = import.meta.glob('../generated-docs/manifest.json', {
12
12
  import: 'default',
13
13
  eager: true
14
14
  });
15
+ const utilitiesModules = import.meta.glob('../generated-docs/utilities.json', {
16
+ query: '?raw',
17
+ import: 'default',
18
+ eager: true
19
+ });
15
20
  function stem(path) {
16
21
  return path.replace(/^\.\.\/generated-docs\//, '').replace(/\.(md|txt)$/, '');
17
22
  }
@@ -19,5 +24,7 @@ export const docsBySlug = Object.fromEntries(Object.entries(markdownFiles).map((
19
24
  const manifest = Object.values(manifestModules)[0];
20
25
  const manifestSlugs = manifest?.entries?.map((entry) => entry.slug) ?? [];
21
26
  export const knownSlugs = new Set(manifestSlugs.length ? manifestSlugs : Object.keys(docsBySlug));
27
+ /** Served verbatim at /utilities.json so tooling can enumerate the CSS API. */
28
+ export const utilitiesJson = utilitiesModules['../generated-docs/utilities.json'] ?? '';
22
29
  export const llmsTxtTemplate = textFiles['../generated-docs/llms.txt'] ?? '';
23
30
  export const llmsFullTxt = textFiles['../generated-docs/llms-full.txt'] ?? '';
@@ -1,5 +1,6 @@
1
1
  import { components, guides } from '../../routes/registry.js';
2
2
  import { buildComponentDoc } from './component-docs.js';
3
+ import { utilitiesDoc } from './utilities.js';
3
4
  const rootUiIndexSources = import.meta.glob('../ui/index.ts', {
4
5
  query: '?raw',
5
6
  import: 'default',
@@ -14,6 +15,9 @@ function slugFromPath(path) {
14
15
  return path.replace(/^\.\//, '').replace(/\.md$/, '');
15
16
  }
16
17
  const appendices = Object.fromEntries(Object.entries(markdownModules).map(([path, body]) => [slugFromPath(path), body]));
18
+ // The utilities guide has no component source to synthesize from — its body is
19
+ // parsed out of the `@utility` doc comments in src/lib/styles instead.
20
+ appendices.utilities = [appendices.utilities, utilitiesDoc].filter(Boolean).join('\n\n');
17
21
  function titleFromSlug(slug) {
18
22
  return slug
19
23
  .split('-')
@@ -1,4 +1,6 @@
1
1
  import { docs, hasDoc, listEntries } from './index.js';
2
+ import { buildUtilitiesIndex } from './css-utilities.js';
3
+ import { utilities } from './utilities.js';
2
4
  import { modes, defaultMode, surfaces, defaultSurface, corners, defaultCorners, fonts, defaultFont, motions, defaultMotion } from '../ui/provider/index.js';
3
5
  const H1 = '# era-ui';
4
6
  const MISSION = '> A Svelte 5 + Bits UI component library. Every surface — heights, paddings, radii, gaps — derives from a single spacing atom, so density is a one-attribute override at any scope.';
@@ -49,6 +51,17 @@ function orientation(origin) {
49
51
  'Each page is generated from the component source, so props/variants/defaults stay in sync with the code automatically.'
50
52
  ].join('\n');
51
53
  }
54
+ function utilitiesSection(origin) {
55
+ return [
56
+ '## CSS utilities',
57
+ '',
58
+ 'Classes, not components — they need no import beyond the stylesheet. Reach for one of these before hand-rolling the same styling or bending a component into the role.',
59
+ '',
60
+ ...buildUtilitiesIndex(utilities),
61
+ '',
62
+ `Full reference (what each emits, when to use it): \`${origin}/utilities.md\`. Machine-readable list: \`${origin}/utilities.json\`.`
63
+ ].join('\n');
64
+ }
52
65
  export function buildLlmsTxt(origin = '') {
53
66
  const entries = listEntries();
54
67
  const lines = [H1, '', MISSION, '', orientation(origin), ''];
@@ -56,6 +69,7 @@ export function buildLlmsTxt(origin = '') {
56
69
  lines.push('## Components', '');
57
70
  lines.push(entries.map((e) => e.slug).join(', '), '');
58
71
  }
72
+ lines.push(utilitiesSection(origin), '');
59
73
  return lines.join('\n');
60
74
  }
61
75
  // Module-init-cached: the concatenated reference has no request-dependent
@@ -73,7 +87,9 @@ export function buildLlmsFullTxt() {
73
87
  '',
74
88
  orientation(''),
75
89
  '',
76
- '<!-- Full component documentation, concatenated. -->',
90
+ utilitiesSection(''),
91
+ '',
92
+ '<!-- Full component documentation, concatenated. The utilities page below carries the CSS each utility emits. -->',
77
93
  ''
78
94
  ];
79
95
  for (const e of entries) {
@@ -0,0 +1,7 @@
1
+ ## Notes
2
+
3
+ - For a region that should scroll but show no scrollbar at all — an overflowing
4
+ tab strip, a chip rail, a small pane handle — you don't need ScrollArea: put
5
+ the `scrollbar-none` CSS utility on the overflowing element. See
6
+ [Utilities](/utilities.md). ScrollArea is for the case where the bar itself is
7
+ part of the design.
@@ -0,0 +1,4 @@
1
+ import { type CssUtility } from './css-utilities.js';
2
+ export declare const utilities: CssUtility[];
3
+ export declare const utilitiesDoc: string;
4
+ export type { CssUtility };
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Runtime side of the CSS-utility docs: globs the shipped stylesheets and parses
3
+ * their `@utility` blocks. The node build script (scripts/build-llm-docs.ts)
4
+ * feeds the same parser from the filesystem, so the site and the generated
5
+ * markdown can never disagree about what era exposes.
6
+ */
7
+ import { buildUtilitiesDoc, parseUtilities } from './css-utilities.js';
8
+ const cssSources = import.meta.glob('../styles/**/*.css', {
9
+ query: '?raw',
10
+ import: 'default',
11
+ eager: true
12
+ });
13
+ const sources = Object.fromEntries(Object.entries(cssSources).map(([path, body]) => [path.replace(/^\.\.\/styles\//, ''), body]));
14
+ export const utilities = parseUtilities(sources);
15
+ export const utilitiesDoc = buildUtilitiesDoc(utilities);