@signal9/era-ui 30.1.2 → 30.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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [30.1.4](https://github.com/sig-nine/era-ui/compare/v30.1.3...v30.1.4) (2026-08-16)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **button:** loading no longer changes the control's width ([7931edb](https://github.com/sig-nine/era-ui/commit/7931edbdb7cbcabf496586983836fc8bf1832e42))
6
+
7
+ ## [30.1.3](https://github.com/sig-nine/era-ui/compare/v30.1.2...v30.1.3) (2026-08-16)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **os:** stop printing a framework warning into consumer consoles ([312e1d5](https://github.com/sig-nine/era-ui/commit/312e1d531f4df19443282917819700b66f37ccfe))
12
+
1
13
  ## [30.1.2](https://github.com/sig-nine/era-ui/compare/v30.1.1...v30.1.2) (2026-08-16)
2
14
 
3
15
  ### Bug Fixes
@@ -162,9 +162,21 @@
162
162
  <div class="absolute inset-0 isolate">
163
163
  <!-- Only the active workspace paints; other workspaces' panes keep
164
164
  their state untouched and simply aren't in the tree. Keyed by id, so
165
- switching back remounts them exactly where they were. -->
166
- {#each pm.activePanes as pane (pane.id)}
167
- <Pane {pane} />
165
+ switching back remounts them exactly where they were.
166
+
167
+ ITERATING pm.panes AND FILTERING HERE, rather than iterating the
168
+ `activePanes` derived, is what makes `bind:` below legal: a pane's
169
+ geometry is written by Pane.Root through two bindings, and Svelte's
170
+ ownership check wants that declared the whole way up — but you cannot
171
+ bind to an item of a DERIVED array. `pm.panes[i]` is the same object
172
+ the derived would have yielded, in the same order (activePanes is a
173
+ pure filter), and the {#if} unmounts exactly what the filter excluded.
174
+ Without this the shell logged ownership_invalid_binding twice per
175
+ pane into every consumer's dev console. -->
176
+ {#each pm.panes as pane, i (pane.id)}
177
+ {#if pane.workspaceId === pm.activeWorkspaceId}
178
+ <Pane bind:pane={pm.panes[i]} />
179
+ {/if}
168
180
  {/each}
169
181
  </div>
170
182
  {@render children?.()}
@@ -8,7 +8,16 @@
8
8
  import { cn } from '../utils/index.js';
9
9
  import { getPaneManager, type AppPane } from './pane-manager.svelte.js';
10
10
 
11
- let { pane }: { pane: AppPane } = $props();
11
+ // $bindable, though nothing here ever reassigns it. Pane.Root takes
12
+ // `position` and `size` with bind:, which WRITES to pane.pos / pane.size —
13
+ // state this component does not own. Svelte's dev-mode ownership check
14
+ // followed that write up the tree, found no declared binding, and logged
15
+ // ownership_invalid_binding on every pane mount: era's own shell printing
16
+ // two framework warnings per load into every consumer's dev console.
17
+ // Declaring the binding here and using it in desktop.svelte is Svelte's own
18
+ // remedy for the message; it costs nothing at runtime and says out loud
19
+ // what the component has always done.
20
+ let { pane = $bindable() }: { pane: AppPane } = $props();
12
21
 
13
22
  const pm = getPaneManager();
14
23
  const focused = $derived(pm.focusedId === pane.id);
@@ -3,6 +3,6 @@ import { type AppPane } from './pane-manager.svelte.js';
3
3
  type $$ComponentProps = {
4
4
  pane: AppPane;
5
5
  };
6
- declare const Pane: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ declare const Pane: import("svelte").Component<$$ComponentProps, {}, "pane">;
7
7
  type Pane = ReturnType<typeof Pane>;
8
8
  export default Pane;
@@ -28,9 +28,25 @@
28
28
  active?: boolean;
29
29
  icon?: boolean;
30
30
  /**
31
- * When true, disables the button, sets `aria-busy`, and prepends a
32
- * spinner. The existing label stays rendered so focus, click targets,
33
- * and screen-reader accessible names don't change shape mid-flight.
31
+ * In flight: disables the button, sets `aria-busy`, and puts a spinner
32
+ * over the label.
33
+ *
34
+ * THE GEOMETRY DOES NOT MOVE. That is the requirement, not a detail. A
35
+ * button is loading because it was just CLICKED, so anything `loading`
36
+ * changes about the box moves the page under the pointer that is still
37
+ * on it — and a spinner PREPENDED to the label (what this used to do)
38
+ * costs an icon's width plus a gap, measured on a wrapping header row
39
+ * that reflowed everything beside it. A consumer hit exactly that and
40
+ * stopped using the prop, hand-rolling `disabled` + `aria-busy`
41
+ * instead, which is the report that produced this note.
42
+ *
43
+ * So the spinner is absolutely positioned and takes no part in the
44
+ * flex layout, and the label goes to `opacity-0` rather than being
45
+ * unmounted: the box that holds the width stays, and the text stays in
46
+ * the accessibility tree as the button's accessible name.
47
+ *
48
+ * The visible label is therefore HIDDEN in flight — the one thing this
49
+ * trades away, and the reason `loadingLabel` exists.
34
50
  */
35
51
  loading?: boolean;
36
52
  /** Screen-reader-only label announced while `loading` is true. */
@@ -109,7 +125,15 @@
109
125
  aria-busy={loading || undefined}
110
126
  data-state={active ? 'on' : undefined}
111
127
  data-size={size}
112
- class={cn(buttonVariants({ variant, tone, size, icon, active }), className)}
128
+ class={cn(
129
+ buttonVariants({ variant, tone, size, icon, active }),
130
+ // Positioned only while loading — the spinner overlay below needs a
131
+ // containing block, and giving EVERY button one would change paint order
132
+ // against its non-positioned neighbours for a state most of them never
133
+ // enter.
134
+ loading && 'relative',
135
+ className
136
+ )}
113
137
  {...restProps}
114
138
  >
115
139
  <!--
@@ -142,6 +166,13 @@
142
166
  data-button-content
143
167
  class={cn(
144
168
  'inline-flex items-center justify-center [gap:inherit]',
169
+ // THE LABEL KEEPS ITS BOX AND LOSES ITS INK. opacity, not `hidden` or
170
+ // `invisible`: the box has to stay (it is what holds the width) and the
171
+ // text has to stay in the accessibility tree (it is the button's
172
+ // accessible name, and a control that loses its name mid-flight is worse
173
+ // than one that shifts). See the `loading` prop for why the spinner
174
+ // cannot simply sit beside it.
175
+ loading && 'opacity-0',
145
176
  // A link is running text, so its wrapper flows inline and takes NO ink
146
177
  // centring. era-label-ink blockifies the box and trims it to the
147
178
  // typographic band — right for a label in a fixed-height control, and
@@ -151,10 +182,6 @@
151
182
  variant === 'link' ? 'inline' : 'not-has-[svg]:era-label-ink'
152
183
  )}
153
184
  >
154
- {#if loading}
155
- <LoaderCircle class="size-icon shrink-0 animate-spin" aria-hidden="true" />
156
- {#if loadingLabel}<span class="sr-only">{loadingLabel}</span>{/if}
157
- {/if}
158
185
  <!-- The label box exists ONLY when `lead`/`trail` are used. Rendering it
159
186
  unconditionally changed the DOM for every legacy call site: a
160
187
  `display:contents` span has no box, and the geometry audits walk real
@@ -171,4 +198,15 @@
171
198
  {@render children?.()}
172
199
  {/if}
173
200
  </span>
201
+ <!--
202
+ The spinner is OUT OF FLOW, which is the whole point. Absolutely positioned
203
+ children take no part in flex layout, so the button's width, height, gap and
204
+ min-w are the same in flight as at rest.
205
+ -->
206
+ {#if loading}
207
+ <span class="absolute inset-0 flex items-center justify-center">
208
+ <LoaderCircle class="size-icon shrink-0 animate-spin" aria-hidden="true" />
209
+ </span>
210
+ {#if loadingLabel}<span class="sr-only">{loadingLabel}</span>{/if}
211
+ {/if}
174
212
  </Button.Root>
@@ -7,9 +7,25 @@ type $$ComponentProps = Button.RootProps & {
7
7
  active?: boolean;
8
8
  icon?: boolean;
9
9
  /**
10
- * When true, disables the button, sets `aria-busy`, and prepends a
11
- * spinner. The existing label stays rendered so focus, click targets,
12
- * and screen-reader accessible names don't change shape mid-flight.
10
+ * In flight: disables the button, sets `aria-busy`, and puts a spinner
11
+ * over the label.
12
+ *
13
+ * THE GEOMETRY DOES NOT MOVE. That is the requirement, not a detail. A
14
+ * button is loading because it was just CLICKED, so anything `loading`
15
+ * changes about the box moves the page under the pointer that is still
16
+ * on it — and a spinner PREPENDED to the label (what this used to do)
17
+ * costs an icon's width plus a gap, measured on a wrapping header row
18
+ * that reflowed everything beside it. A consumer hit exactly that and
19
+ * stopped using the prop, hand-rolling `disabled` + `aria-busy`
20
+ * instead, which is the report that produced this note.
21
+ *
22
+ * So the spinner is absolutely positioned and takes no part in the
23
+ * flex layout, and the label goes to `opacity-0` rather than being
24
+ * unmounted: the box that holds the width stays, and the text stays in
25
+ * the accessibility tree as the button's accessible name.
26
+ *
27
+ * The visible label is therefore HIDDEN in flight — the one thing this
28
+ * trades away, and the reason `loadingLabel` exists.
13
29
  */
14
30
  loading?: boolean;
15
31
  /** Screen-reader-only label announced while `loading` is true. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "30.1.2",
3
+ "version": "30.1.4",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",