@signal9/era-ui 4.7.2 → 4.9.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.
Files changed (48) hide show
  1. package/dist/ai/swarm/index.d.ts +2 -1
  2. package/dist/ai/swarm/index.js +2 -1
  3. package/dist/ai/swarm/swarm-actions.svelte +86 -0
  4. package/dist/ai/swarm/swarm-actions.svelte.d.ts +31 -0
  5. package/dist/ai/swarm/swarm-agent.svelte +9 -43
  6. package/dist/ai/swarm/swarm-artifact.svelte +2 -6
  7. package/dist/ai/swarm/swarm-artifacts.svelte +1 -10
  8. package/dist/ai/swarm/swarm-artifacts.svelte.d.ts +0 -2
  9. package/dist/ai/swarm/swarm-barriers.svelte +39 -0
  10. package/dist/ai/swarm/swarm-barriers.svelte.d.ts +13 -0
  11. package/dist/ai/swarm/swarm-counts.svelte +1 -4
  12. package/dist/ai/swarm/swarm-counts.svelte.d.ts +0 -2
  13. package/dist/ai/swarm/swarm-footer.svelte +20 -13
  14. package/dist/ai/swarm/swarm-header.svelte +3 -5
  15. package/dist/ai/swarm/swarm-node.svelte +8 -64
  16. package/dist/ai/swarm/swarm-role.svelte +2 -9
  17. package/dist/ai/swarm/swarm-sandbox.svelte +45 -0
  18. package/dist/ai/swarm/swarm-sandbox.svelte.d.ts +10 -0
  19. package/dist/ai/swarm/swarm-tree.svelte +22 -8
  20. package/dist/ai/swarm/swarm.d.ts +8 -0
  21. package/dist/ai/swarm/swarm.js +11 -2
  22. package/dist/ai/swarm/swarm.svelte.d.ts +0 -1
  23. package/dist/ai/swarm/swarm.svelte.js +10 -12
  24. package/dist/apps/llm-shell/llm-shell.svelte +2 -36
  25. package/dist/apps/llm-shell/llm-swarm.svelte +64 -0
  26. package/dist/apps/llm-shell/llm-swarm.svelte.d.ts +23 -0
  27. package/dist/apps/llm-shell/types.d.ts +2 -0
  28. package/dist/apps/notes/editor/bubble-toolbar.svelte +2 -1
  29. package/dist/apps/notes/notes.svelte +13 -10
  30. package/dist/dev/audit/audits/clipped-glyphs.js +33 -11
  31. package/dist/docs/llms-txt.js +1 -1
  32. package/dist/era-ui.css +1 -1
  33. package/dist/generated-docs/bar.md +3 -0
  34. package/dist/generated-docs/input.md +3 -1
  35. package/dist/generated-docs/llms-full.txt +15 -6
  36. package/dist/generated-docs/llms.txt +1 -1
  37. package/dist/generated-docs/manifest.json +5 -5
  38. package/dist/generated-docs/utilities.json +2 -2
  39. package/dist/generated-docs/utilities.md +8 -4
  40. package/dist/styles/density.css +42 -9
  41. package/dist/styles/index.css +8 -4
  42. package/dist/ui/bar/bar.svelte +37 -3
  43. package/dist/ui/bar/bar.svelte.d.ts +61 -1
  44. package/dist/ui/input/input.svelte +24 -4
  45. package/dist/ui/input/input.svelte.d.ts +6 -1
  46. package/dist/ui/input/variants.d.ts +99 -30
  47. package/dist/ui/input/variants.js +45 -19
  48. package/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ <script lang="ts" module>
2
+ import type { SwarmSandbox } from './swarm.js';
3
+
4
+ /**
5
+ * VM health → tint. The one place it is spelled: the row showed the glyph and
6
+ * the card showed glyph + word, and each had its own copy of this mapping —
7
+ * which disagreed about `terminated`.
8
+ */
9
+ const SANDBOX_TONE: Record<SwarmSandbox['status'], string> = {
10
+ running: 'text-success',
11
+ paused: 'text-warning',
12
+ terminated: 'text-muted'
13
+ };
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ import Box from '@lucide/svelte/icons/box';
18
+ import { cn } from '../../utils/index.js';
19
+
20
+ let {
21
+ sandbox,
22
+ label = false,
23
+ class: className
24
+ }: {
25
+ sandbox: SwarmSandbox;
26
+ /** Spell the status out beside the glyph — for a card, where there is room. */
27
+ label?: boolean;
28
+ class?: string;
29
+ } = $props();
30
+ </script>
31
+
32
+ <!-- Deliberately a signal of its own, never folded into the status dot: an agent
33
+ can be running with a terminated VM (reaped, or never created), and one
34
+ indicator for both hides exactly the state you opened a console to find. -->
35
+ <span
36
+ class={cn(
37
+ 'flex min-w-0 shrink-0 items-center gap-(--era-gap) font-mono',
38
+ SANDBOX_TONE[sandbox.status],
39
+ className
40
+ )}
41
+ title="sandbox {sandbox.id ?? ''} ({sandbox.status})"
42
+ >
43
+ <Box class="size-(--era-h-xs) shrink-0" aria-hidden="true" />
44
+ {#if label}<span class="truncate">{sandbox.status}</span>{/if}
45
+ </span>
@@ -0,0 +1,10 @@
1
+ import type { SwarmSandbox } from './swarm.js';
2
+ type $$ComponentProps = {
3
+ sandbox: SwarmSandbox;
4
+ /** Spell the status out beside the glyph — for a card, where there is room. */
5
+ label?: boolean;
6
+ class?: string;
7
+ };
8
+ declare const SwarmSandbox: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type SwarmSandbox = ReturnType<typeof SwarmSandbox>;
10
+ export default SwarmSandbox;
@@ -49,7 +49,11 @@
49
49
  const isOpen = (id: string) => !closed[id];
50
50
 
51
51
  function setOpen(agent: SwarmAgent, open: boolean) {
52
- closed = { ...closed, [agent.id]: !open };
52
+ // Mutated in place, not replaced: assigning a fresh object changes the
53
+ // `closed` signal itself, which invalidates EVERY node's props (each reads it
54
+ // through isOpen) — so collapsing one row rebuilt the whole forest's props.
55
+ // The deep proxy notifies only this key's readers.
56
+ closed[agent.id] = !open;
53
57
  onToggle?.(agent, open);
54
58
  }
55
59
 
@@ -58,14 +62,23 @@
58
62
  * order the arrow keys have to move through, so navigation is derived from
59
63
  * the same walk that renders rather than from the DOM.
60
64
  */
61
- type Row = { id: string; parentId: string | null; expandable: boolean; open: boolean };
65
+ // Carries the agent, not just its id: every arrow key that opens or closes a
66
+ // row needs the record, and looking it up again meant an O(n) find plus a
67
+ // null-guard in each branch of the key handler.
68
+ type Row = {
69
+ id: string;
70
+ agent: SwarmAgent;
71
+ parentId: string | null;
72
+ expandable: boolean;
73
+ open: boolean;
74
+ };
62
75
  const rows = $derived.by(() => {
63
76
  const out: Row[] = [];
64
77
  const walk = (nodes: SwarmNode[], parentId: string | null) => {
65
78
  for (const node of nodes) {
66
79
  const canExpand = nodeExpandable(node);
67
80
  const open = isOpen(node.agent.id);
68
- out.push({ id: node.agent.id, parentId, expandable: canExpand, open });
81
+ out.push({ id: node.agent.id, agent: node.agent, parentId, expandable: canExpand, open });
69
82
  if (canExpand && open) walk(node.children, node.agent.id);
70
83
  }
71
84
  };
@@ -117,8 +130,7 @@
117
130
  if (!current) break;
118
131
  event.preventDefault();
119
132
  if (current.expandable && !current.open) {
120
- const agent = agents.find((a) => a.id === current.id);
121
- if (agent) setOpen(agent, true);
133
+ setOpen(current.agent, true);
122
134
  } else if (rows[index + 1]?.parentId === current.id) {
123
135
  focusRow(rows[index + 1].id);
124
136
  }
@@ -128,8 +140,7 @@
128
140
  if (!current) break;
129
141
  event.preventDefault();
130
142
  if (current.expandable && current.open) {
131
- const agent = agents.find((a) => a.id === current.id);
132
- if (agent) setOpen(agent, false);
143
+ setOpen(current.agent, false);
133
144
  } else {
134
145
  focusRow(current.parentId);
135
146
  }
@@ -185,8 +196,11 @@
185
196
  detail,
186
197
  meta,
187
198
  open: isOpen(node.agent.id),
199
+ // `active` is the whole roving-focus story: the node derives its own
200
+ // tabindex from it. Passing tabindex separately meant the same fact twice,
201
+ // and it only worked because the node happened to spread restProps AFTER
202
+ // its own tabindex — reorder those two lines and Tab access dies silently.
188
203
  active: focusedId === node.agent.id,
189
- tabindex: focusedId === node.agent.id ? 0 : -1,
190
204
  onToggle: (open: boolean) => setOpen(node.agent, open)
191
205
  }}
192
206
  {#if node.children.length > 0}
@@ -175,6 +175,14 @@ export declare function summarizeBarrier(barrier: SwarmBarrier, agents: SwarmAge
175
175
  total: number;
176
176
  status: SwarmBarrierStatus;
177
177
  };
178
+ /**
179
+ * Work that has not landed yet — blocked, queued or running.
180
+ *
181
+ * The predicate a cancel affordance is gated on, and the definition of `active`
182
+ * in `summarizeSwarm`. One function so the two can never disagree about whether
183
+ * a blocked agent is still cancellable.
184
+ */
185
+ export declare function isSwarmAgentActive(agent: SwarmAgent): boolean;
178
186
  /**
179
187
  * How long an agent has been working, in ms — `null` when it never started.
180
188
  *
@@ -100,9 +100,8 @@ export function summarizeSwarm(agents) {
100
100
  };
101
101
  for (const agent of agents) {
102
102
  counts[agent.status]++;
103
- if (agent.status === 'blocked' || agent.status === 'queued' || agent.status === 'running') {
103
+ if (isSwarmAgentActive(agent))
104
104
  counts.active++;
105
- }
106
105
  if (agent.sandbox && agent.sandbox.status !== 'terminated')
107
106
  counts.sandboxes++;
108
107
  }
@@ -145,6 +144,16 @@ export function summarizeBarrier(barrier, agents) {
145
144
  : 'done';
146
145
  return { done, failed, total, status: barrier.status ?? (total === 0 ? 'open' : derived) };
147
146
  }
147
+ /**
148
+ * Work that has not landed yet — blocked, queued or running.
149
+ *
150
+ * The predicate a cancel affordance is gated on, and the definition of `active`
151
+ * in `summarizeSwarm`. One function so the two can never disagree about whether
152
+ * a blocked agent is still cancellable.
153
+ */
154
+ export function isSwarmAgentActive(agent) {
155
+ return agent.status === 'blocked' || agent.status === 'queued' || agent.status === 'running';
156
+ }
148
157
  /**
149
158
  * How long an agent has been working, in ms — `null` when it never started.
150
159
  *
@@ -38,7 +38,6 @@ export declare class SwarmController {
38
38
  /** The watermark: the last APPLIED event seq, and the resume point. */
39
39
  seq: number;
40
40
  constructor(snapshot?: SwarmSnapshot);
41
- readonly tree: import("./swarm.js").SwarmNode[];
42
41
  readonly counts: import("./swarm.js").SwarmCounts;
43
42
  /**
44
43
  * Merge a snapshot in.
@@ -6,7 +6,7 @@
6
6
  * the only one a host has to construct). See that module for the model itself
7
7
  * and for why the sequence number is the whole resumption contract.
8
8
  */
9
- import { buildSwarmTree, summarizeSwarm } from './swarm.js';
9
+ import { summarizeSwarm } from './swarm.js';
10
10
  /**
11
11
  * Merge an agent patch over what is already known.
12
12
  *
@@ -21,12 +21,18 @@ function mergeAgent(prev, next) {
21
21
  const patch = Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined));
22
22
  return { ...prev, ...patch };
23
23
  }
24
- function upsert(list, item) {
24
+ /**
25
+ * Replace-or-append by id, immutably. `merge` is how the two kinds of record
26
+ * differ: barriers and artifacts arrive whole, agents arrive as patches (see
27
+ * `mergeAgent`), and that is the only difference — so it is a parameter rather
28
+ * than a second copy of the find-index/clone/splice dance.
29
+ */
30
+ function upsert(list, item, merge = (prev, next) => ({ ...prev, ...next })) {
25
31
  const index = list.findIndex((existing) => existing.id === item.id);
26
32
  if (index < 0)
27
33
  return [...list, item];
28
34
  const next = [...list];
29
- next[index] = { ...next[index], ...item };
35
+ next[index] = merge(next[index], item);
30
36
  return next;
31
37
  }
32
38
  /**
@@ -62,7 +68,6 @@ export class SwarmController {
62
68
  if (snapshot)
63
69
  this.snapshot(snapshot);
64
70
  }
65
- tree = $derived(buildSwarmTree(this.agents));
66
71
  counts = $derived(summarizeSwarm(this.agents));
67
72
  /**
68
73
  * Merge a snapshot in.
@@ -113,13 +118,6 @@ export class SwarmController {
113
118
  this.seq = -1;
114
119
  }
115
120
  #mergeAgent(agent) {
116
- const index = this.agents.findIndex((existing) => existing.id === agent.id);
117
- if (index < 0) {
118
- this.agents = [...this.agents, agent];
119
- return;
120
- }
121
- const next = [...this.agents];
122
- next[index] = mergeAgent(next[index], agent);
123
- this.agents = next;
121
+ this.agents = upsert(this.agents, agent, mergeAgent);
124
122
  }
125
123
  }
@@ -27,6 +27,7 @@
27
27
  import Sparkles from '@lucide/svelte/icons/sparkles';
28
28
  import Plus from '@lucide/svelte/icons/plus';
29
29
  import { cn } from '../../utils/index.js';
30
+ import LlmSwarmBlock from './llm-swarm.svelte';
30
31
  import type { ChatStatus, LlmBlock, LlmConversation, LlmMessage, RunState } from './types.js';
31
32
 
32
33
  let {
@@ -277,42 +278,7 @@
277
278
  </AI.Tool.Content>
278
279
  </AI.Tool.Root>
279
280
  {:else if block.kind === 'swarm'}
280
- <!-- A delegated run, inline in the turn that started it. The tree keeps the
281
- delegation shape (who asked whom, what is still open); `view="grid"`
282
- turns the same agents into the console layout for a wide pane.
283
- Everything reads off block.swarm, so a live SwarmController streams
284
- straight into it — the app never copies swarm state around. -->
285
- {@const swarm = block.swarm}
286
- {@const artifactsFor = (agent: AI.Swarm.SwarmAgent) =>
287
- (swarm.artifacts ?? []).filter((a) => a.agentId === agent.id)}
288
- <AI.Swarm.Root {now} onOpen={onOpenAgent} onCancel={onCancelAgent}>
289
- <AI.Swarm.Header title={swarm.plan?.title} status={swarm.plan?.status}>
290
- <AI.Swarm.Counts agents={swarm.agents} />
291
- </AI.Swarm.Header>
292
-
293
- {#if block.view === 'grid'}
294
- <AI.Swarm.Grid agents={swarm.agents} />
295
- {:else}
296
- <AI.Swarm.Tree agents={swarm.agents} expandable={(agent) => artifactsFor(agent).length > 0}>
297
- {#snippet detail(agent)}
298
- <AI.Swarm.Artifacts artifacts={artifactsFor(agent)} />
299
- {/snippet}
300
- </AI.Swarm.Tree>
301
- {/if}
302
-
303
- {#if (swarm.barriers ?? []).length > 0}
304
- <!-- Fan-in gates last: they are what the branches above are waiting on. -->
305
- <div
306
- class="flex flex-col gap-(--era-pad-sm) border-t border-divider-faded p-(--era-pad-sm)"
307
- >
308
- {#each swarm.barriers ?? [] as barrier (barrier.id)}
309
- <AI.Swarm.Barrier {barrier} agents={swarm.agents} />
310
- {/each}
311
- </div>
312
- {/if}
313
-
314
- <AI.Swarm.Footer connection={swarm.connection} seq={swarm.seq} />
315
- </AI.Swarm.Root>
281
+ <LlmSwarmBlock swarm={block.swarm} view={block.view} {now} {onOpenAgent} {onCancelAgent} />
316
282
  {:else if block.kind === 'approval'}
317
283
  <AI.Confirmation.Root state={approvalState}>
318
284
  Approve <span class="font-mono text-fg">{block.toolName}</span>? Review the arguments before
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The `swarm` block: a delegated run, inline in the turn that started it.
4
+ *
5
+ * Its own component rather than a branch of llm-shell's blockView, because it
6
+ * needs one thing a snippet cannot hold — a $derived index of artifacts by
7
+ * agent. Inline, the lookup was a `{@const}` arrow re-created on every render
8
+ * and called once per node by the tree's expandable predicate, again by the
9
+ * keyboard walk, and again by the detail body: O(agents x artifacts) per event,
10
+ * three times over. Here it is one pass per artifact change.
11
+ */
12
+ import * as AI from '../../ai/index.js';
13
+ import type { LlmSwarm } from './types.js';
14
+
15
+ let {
16
+ swarm,
17
+ view = 'tree',
18
+ now = Date.now(),
19
+ onOpenAgent,
20
+ onCancelAgent
21
+ }: {
22
+ swarm: LlmSwarm;
23
+ /** `tree` keeps the delegation shape; `grid` is the console layout. */
24
+ view?: 'tree' | 'grid';
25
+ now?: number;
26
+ onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
27
+ onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
28
+ } = $props();
29
+
30
+ // A plain record, not a Map: it is rebuilt whole by the derived, so it never
31
+ // needs reactivity of its own (and SvelteMap here would only add proxy cost).
32
+ // NONE is shared so a miss doesn't allocate a fresh array per lookup per render.
33
+ const NONE: AI.Swarm.SwarmArtifact[] = [];
34
+ const byAgent = $derived.by(() => {
35
+ const index: Record<string, AI.Swarm.SwarmArtifact[]> = {};
36
+ for (const artifact of swarm.artifacts ?? []) {
37
+ if (artifact.agentId == null) continue;
38
+ (index[artifact.agentId] ??= []).push(artifact);
39
+ }
40
+ return index;
41
+ });
42
+ const artifactsFor = (agent: AI.Swarm.SwarmAgent) => byAgent[agent.id] ?? NONE;
43
+ </script>
44
+
45
+ <AI.Swarm.Root {now} onOpen={onOpenAgent} onCancel={onCancelAgent}>
46
+ <AI.Swarm.Header title={swarm.plan?.title} status={swarm.plan?.status}>
47
+ <AI.Swarm.Counts agents={swarm.agents} />
48
+ </AI.Swarm.Header>
49
+
50
+ {#if view === 'grid'}
51
+ <AI.Swarm.Grid agents={swarm.agents} />
52
+ {:else}
53
+ <AI.Swarm.Tree agents={swarm.agents} expandable={(agent) => artifactsFor(agent).length > 0}>
54
+ {#snippet detail(agent)}
55
+ <AI.Swarm.Artifacts artifacts={artifactsFor(agent)} />
56
+ {/snippet}
57
+ </AI.Swarm.Tree>
58
+ {/if}
59
+
60
+ <!-- Fan-in gates last: they are what the branches above are waiting on. -->
61
+ <AI.Swarm.Barriers barriers={swarm.barriers ?? []} agents={swarm.agents} />
62
+
63
+ <AI.Swarm.Footer connection={swarm.connection} seq={swarm.seq} error={swarm.error} />
64
+ </AI.Swarm.Root>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `swarm` block: a delegated run, inline in the turn that started it.
3
+ *
4
+ * Its own component rather than a branch of llm-shell's blockView, because it
5
+ * needs one thing a snippet cannot hold — a $derived index of artifacts by
6
+ * agent. Inline, the lookup was a `{@const}` arrow re-created on every render
7
+ * and called once per node by the tree's expandable predicate, again by the
8
+ * keyboard walk, and again by the detail body: O(agents x artifacts) per event,
9
+ * three times over. Here it is one pass per artifact change.
10
+ */
11
+ import * as AI from '../../ai/index.js';
12
+ import type { LlmSwarm } from './types.js';
13
+ type $$ComponentProps = {
14
+ swarm: LlmSwarm;
15
+ /** `tree` keeps the delegation shape; `grid` is the console layout. */
16
+ view?: 'tree' | 'grid';
17
+ now?: number;
18
+ onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
19
+ onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
20
+ };
21
+ declare const LlmSwarm: import("svelte").Component<$$ComponentProps, {}, "">;
22
+ type LlmSwarm = ReturnType<typeof LlmSwarm>;
23
+ export default LlmSwarm;
@@ -36,6 +36,8 @@ export type LlmSwarm = {
36
36
  artifacts?: SwarmArtifact[];
37
37
  connection?: SwarmConnection;
38
38
  seq?: number;
39
+ /** Last transport error. A `SwarmController` exposes this under the same name. */
40
+ error?: string | null;
39
41
  };
40
42
  /**
41
43
  * Everything an assistant turn can contain.
@@ -32,7 +32,8 @@
32
32
  it still tracks the surface rather than being a drawn-on line. -->
33
33
  <Bar
34
34
  size="md"
35
- class="w-max gap-(--era-xxs-inset-md) border border-divider-faded bg-elevated px-(--era-xxs-inset-md) shadow-lg glass-blur"
35
+ content="xxs"
36
+ class="w-max border border-divider-faded bg-elevated shadow-lg glass-blur"
36
37
  >
37
38
  {#each items as item (item.mark)}
38
39
  {@const Icon = item.icon}
@@ -151,22 +151,24 @@
151
151
  >
152
152
  <!-- Sidebar: filter + note list -->
153
153
  <div class="flex w-56 shrink-0 flex-col border-r border-divider-faded">
154
- <!-- Default lg bar holding md controls, which is the pairing Bar documents:
155
- (32-24)/2 lands on the bar's own xs-inset-sm padding, so the field sits
156
- concentric. This was md with a chromeless field compact, but it made
157
- the filter the ONLY input in the library without a field fill. At md a
158
- real field is the same height as the bar and overflows it by the
159
- border. -->
160
- <Bar class="shrink-0 rounded-none border-b border-divider-faded">
154
+ <!-- content="xxs": 24/30/36/48 of chrome around an 18/20/22/26 field and
155
+ button, with Bar deriving the two-tier concentric inset. A notes sidebar
156
+ is chrome around a list; at the lg/md pairing it was a 32px bar over
157
+ 24px rows and the chrome outweighed the content. This is the tier pair
158
+ term's notes app uses its hardcoded 3px is what Bar now derives at
159
+ dense. The field keeps its fill: xxs is the smallest tier a real field
160
+ fits on. -->
161
+ <Bar size="md" content="xxs" class="shrink-0 rounded-none border-b border-divider-faded">
161
162
  <Input
162
163
  icon={Search}
164
+ size="xxs"
163
165
  class="min-w-0 flex-1"
164
166
  type="text"
165
167
  placeholder="Filter…"
166
168
  aria-label="Filter notes"
167
169
  bind:value={query}
168
170
  />
169
- <Button icon aria-label="New note" title="New note" onclick={createNote}>
171
+ <Button icon size="xxs" aria-label="New note" title="New note" onclick={createNote}>
170
172
  <Plus />
171
173
  </Button>
172
174
  </Bar>
@@ -312,9 +314,9 @@
312
314
  {#if selected}
313
315
  <!-- Matches the sidebar's filter bar exactly — the two headers sit side by
314
316
  side across the top of the app, so they must be the same tier. -->
315
- <Bar class="shrink-0 rounded-none border-b border-divider-faded">
317
+ <Bar size="md" content="xxs" class="shrink-0 rounded-none border-b border-divider-faded">
316
318
  <Popover.Root bind:open={iconPickerOpen}>
317
- <Popover.Trigger icon aria-label="Change icon" title="Change icon">
319
+ <Popover.Trigger icon size="xxs" aria-label="Change icon" title="Change icon">
318
320
  {#if selected.icon}
319
321
  <span aria-hidden="true">{selected.icon}</span>
320
322
  {:else}
@@ -359,6 +361,7 @@
359
361
 
360
362
  <Input
361
363
  reveal
364
+ size="xxs"
362
365
  class="min-w-0 flex-1"
363
366
  aria-label="Note title"
364
367
  placeholder="Untitled"
@@ -23,7 +23,7 @@ const INK_RATIO = 1.15;
23
23
  export const clippedGlyphs = {
24
24
  id: 'typography/clipped-glyphs',
25
25
  name: 'Clipping text box is taller than its ink',
26
- description: 'An element that clips its overflow must be at least as tall as its font paints (~1.15em), or leading-none slices the ascenders and descenders off its own text.',
26
+ description: 'An element that clips its overflow must be at least as tall as the ink it holds — ~1.15em under an authored line-height, or its own line box when the height is constrained — or the clip slices the ascenders and descenders off its text.',
27
27
  category: 'layout',
28
28
  severity: 'error',
29
29
  selector: '*',
@@ -36,24 +36,46 @@ export const clippedGlyphs = {
36
36
  const s = getComputedStyle(el);
37
37
  if (!/hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY))
38
38
  return null;
39
- // `normal` IS the ink box: the UA sizes the line from the font's own
40
- // ascent + descent, so the box cannot be shorter than what the font paints,
41
- // whatever ratio that works out to (a serif at 14px lands at 16px = 1.14em,
42
- // under the ratio below and perfectly safe). Only an AUTHORED line-height
43
- // can starve the box, so only an authored one is measured.
44
- if (s.lineHeight === 'normal')
45
- return null;
46
39
  const fontSize = parseFloat(s.fontSize) || 14;
47
40
  const h = el.getBoundingClientRect().height;
48
41
  if (h === 0)
49
42
  return null;
50
- if (h >= fontSize * INK_RATIO)
43
+ /*
44
+ * Two ways a clipping box can be too short, and they need different tests.
45
+ *
46
+ * An AUTHORED line-height sets the box, so the box is measurable against the
47
+ * ink: shorter than ~1.15em and the glyphs lose their tails.
48
+ *
49
+ * `normal` sets the box from the font's own ascent + descent, so the LINE box
50
+ * is the ink box by construction and the ratio says nothing (a serif at 14px
51
+ * lands at 16px = 1.14em, under the ratio and perfectly safe). But the
52
+ * ELEMENT can still be shorter than that line box — an explicit height, a
53
+ * flex or grid constraint, a max-height — and then the clip bites exactly as
54
+ * hard. That case is what scrollHeight reports: content taller than the box
55
+ * it is being clipped into. An earlier version of this audit bailed on
56
+ * `normal` outright and so could never fire on era's own `truncate`, which
57
+ * sets line-height: normal — the exact construct the description names.
58
+ */
59
+ const authored = s.lineHeight !== 'normal';
60
+ const starved = authored ? h < fontSize * INK_RATIO : el.scrollHeight - el.clientHeight > 1;
61
+ if (!starved)
51
62
  return null;
63
+ const need = authored
64
+ ? `~${(fontSize * INK_RATIO).toFixed(1)}px`
65
+ : `${el.scrollHeight}px (its own line box)`;
52
66
  const issue = {
53
67
  auditId: 'typography/clipped-glyphs',
54
68
  element: el,
55
- message: `clipping box is ${h.toFixed(1)}px tall for ${fontSize}px text — it needs ~${(fontSize * INK_RATIO).toFixed(1)}px, so the descenders are sliced off ("g" loses its hook). line-height is ${s.lineHeight}; give the clipping element line-height: normal (era's truncate does) or move the clip to a taller row.`,
56
- details: { height: h, fontSize, lineHeight: s.lineHeight, text: text.slice(0, 40) }
69
+ message: `clipping box is ${h.toFixed(1)}px tall for ${fontSize}px text — it needs ${need}, so the ascenders and descenders are sliced off ("g" loses its hook). line-height is ${s.lineHeight}; ${authored
70
+ ? "give the clipping element line-height: normal (era's truncate does)"
71
+ : 'the box is constrained shorter than its line box — drop the height'} or move the clip to a taller row.`,
72
+ details: {
73
+ height: h,
74
+ fontSize,
75
+ lineHeight: s.lineHeight,
76
+ lineBox: el.scrollHeight,
77
+ text: text.slice(0, 40)
78
+ }
57
79
  };
58
80
  return issue;
59
81
  }
@@ -40,7 +40,7 @@ function orientation(origin) {
40
40
  'lg = 4·sp bars, top-level containers 32 / 40 / 48 px',
41
41
  '```',
42
42
  '',
43
- 'Heights (`--era-h-*`), insets (`--era-inset-*`), radii (`--era-rd-*`), and concentric gaps (`--era-xs-inset-sm`, `--era-xxs-inset-md`, …) are all `calc()` chains off `--era-sp`. Never hard-code pixel values — setting `data-mode` on any ancestor re-derives the whole subtree. Text size is static (14 px) across modes; only the spacing atom moves.',
43
+ 'Heights (`--era-h-*`), insets (`--era-inset-*`), radii (`--era-rd-*`), and concentric gaps (`--era-xs-inset-sm`, `--era-xxs-inset-md`, …) are all `calc()` chains off `--era-sp`. Never hard-code pixel values — setting `data-mode` on any ancestor re-derives the whole subtree. Type is the second density input (12 px at dense, 14 px above); the spacing atom moves with it.',
44
44
  '',
45
45
  '## Per-component docs',
46
46
  '',