@micha.bigler/ui-core-micha 2.42.3 → 3.0.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.
@@ -0,0 +1,348 @@
1
+ # UCM-CHART-12 — One layout model for charts. Breaking, and the end of this series.
2
+
3
+ - **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main`, no staging step)
4
+ - **Tier:** 3 — shared-core, **BREAKING**: major version, `3.0.0`.
5
+ - **Status:** planned
6
+ - **Workstream:** `CHART-*`
7
+ - **Files:** `src/components/charts/chartDefaults.js` (the new resolver; `resolveChartHeight`,
8
+ `spaceForRotatedTicks`, `warnOnHeightMismatch` are **deleted**), the four presets, `ChartFrame.jsx`,
9
+ their tests, and a new `docs/CHART-LAYOUT.md` migration guide.
10
+ - **Consumers are NOT migrated here.** Operator decision 2026-08-22: ucm first, apps one at a time,
11
+ each its own WO gated on its pin bump.
12
+
13
+ > **Why — four work orders is the evidence, not the anecdote.**
14
+ >
15
+ > | WO | what it corrected |
16
+ > |---|---|
17
+ > | `UCM-CHART-8` | `minHeight` vs `height` — wrapper reserved space the chart did not use |
18
+ > | `UCM-CHART-9` | `ChartFrame` given a fixed `height` it must never have |
19
+ > | `UCM-CHART-10` | rotated-tick allowance added to **both** `axis.height` and `margin.bottom` |
20
+ > | `UCM-CHART-11` | that same allowance **estimated** above the rendered text |
21
+ >
22
+ > Each fixed exactly one term of an equation nobody had written down. A fifth is already visible
23
+ > (`ScatterChart`'s x-axis band stays reserved when the tick labels are empty — hram's
24
+ > `AccessGapScatterPanel` shows 58 px of blank inside the SVG where the labels should be), and the
25
+ > operator has called a stop: **no more term-by-term corrections.**
26
+ >
27
+ > **The root cause is structural: no single place owns a chart's composition.** Five interacting
28
+ > knobs — `minHeight`, `height`, `aspect`, `margin`, `xAxisAngle` — are assembled differently by each
29
+ > preset, and consumers add pixel arithmetic of their own on top (`margin.bottom: 58` in
30
+ > `AccessGapScatterPanel`, `margin.left: 220` in hram's tornado). Every one of those is the same
31
+ > disease: **space reserved in one place and again in another, or reserved as a constant where the
32
+ > content is variable.**
33
+ >
34
+ > **Measured consumer surface, 2026-08-22** — this is why breaking is cheaper than continuing:
35
+ >
36
+ > | app | chart files | of which use `minHeight` |
37
+ > |---|---|---|
38
+ > | hram | 29 | 12 |
39
+ > | fitness-monitor | 5 | 2 |
40
+ > | cockpit / jg-ferien / spesix | 4 | 0 |
41
+ > | **total** | **38** | **14** |
42
+ >
43
+ > 76 % sits in one app. This is a sweep, not a migration programme.
44
+
45
+ ---
46
+
47
+ ## Part A — Envelope (authoritative WHAT/WHY)
48
+
49
+ ### Goal
50
+
51
+ One function owns a chart's geometry. Every reserved band is derived from the content it holds and
52
+ collapses to zero when that content is empty. Consumers express intent, never pixels. And a test
53
+ asserts the composition adds up, so this class of defect cannot recur.
54
+
55
+ ### The model — three rules
56
+
57
+ **Rule 1 — one size source, no interaction.**
58
+ A chart's height comes from a **named size token**: `size="compact" | "standard" | "tall"`, resolved
59
+ through the theme's spacing scale. `height` in pixels survives as a **documented escape** for the
60
+ justified special case, and the JSDoc says it requires a reason.
61
+ - **`minHeight` is removed from all four presets.** A floor is a *card* concern, not a chart one.
62
+ - **`aspect` is removed entirely** (operator decision 2026-08-22). One size source, system-wide.
63
+ - **`resolveChartHeight` is therefore deleted, not amended** — with `minHeight` and `aspect` both
64
+ gone, its three-way resolution has nothing left to resolve. Do not preserve it out of caution.
65
+
66
+ **Rule 2 — every band is its own measured content, or zero.**
67
+ Height decomposes into `plot + xAxisBand + xTitleBand + legendBand`; width into
68
+ `yAxisBand + plot + rightPad`. Each band is sized from what it actually contains, measured in **one**
69
+ place. **An empty band is zero.** That single sentence is the fifth defect's fix: reserved space for
70
+ tick labels that turn out empty must collapse, not persist.
71
+ - No band is ever a constant.
72
+ - No term appears in two addends. That is what `UCM-CHART-10` was.
73
+
74
+ **Rule 3 — consumers pass intent, never pixels.**
75
+ - **No `margin` prop on the presets.** The model owns margins completely. Spacing between a chart and
76
+ its neighbours is the card's job.
77
+ - **`xAxisAngle` is replaced by `xLabels: "auto" | "horizontal" | "angled"`.** Under `"auto"` the
78
+ model decides from the *measured* labels and the available width whether rotation is needed. A
79
+ consumer no longer states a rotation and separately pays for its geometry.
80
+
81
+ ### The invariant — the actual deliverable
82
+
83
+ ```
84
+ chartHeight === plotHeight + xAxisBand + xTitleBand + legendBand
85
+ chartWidth === yAxisBand + plotWidth + rightPad
86
+ ```
87
+
88
+ Asserted as a test, per preset, over a matrix of label loads: none, short, long, long-and-many,
89
+ empty-strings.
90
+
91
+ **`UCM-CHART-8`, `-10` and `-11` would each have failed this on day one.** The width line catches the
92
+ two findings still open elsewhere: a scatter y-axis reserving 72 px for a 33 px label, and the
93
+ tornado's 220 px gutter. **This assertion, not the fix, is what ends the series** — and it is the one
94
+ thing in this WO that must not be dropped for expedience.
95
+
96
+ ### Definition of Done
97
+
98
+ - [ ] `resolveChartLayout({ size | height, xLabels, xTitle, yTitle, legend, ticks, measureText })`
99
+ returns the **complete** geometry — wrapper `sx`, chart height, margins, and both axis sizes.
100
+ Nothing outside it does chart arithmetic.
101
+ - [ ] `size` tokens resolve through the theme spacing scale; `height` documented as the escape.
102
+ - [ ] `minHeight`, `aspect` and `margin` are gone from the four presets. Passing one is a **dev-mode
103
+ error naming the replacement**, not a silent ignore — 38 call sites will hit this and the
104
+ message is the migration guide.
105
+ - [ ] `ChartFrame` **keeps** its `minHeight` as a card floor. `UCM-CHART-9` established that a frame
106
+ is not a chart; do not sweep the frame's floor away with the presets' prop.
107
+ - [ ] `resolveChartHeight`, `spaceForRotatedTicks`, `warnOnHeightMismatch` deleted.
108
+ - [ ] Both invariants asserted, over the label matrix above.
109
+ - [ ] **Empty tick labels produce a zero-height axis band**, tested explicitly. This is the
110
+ Access-scatter case and it is the reason the model exists.
111
+ - [ ] `docs/CHART-LAYOUT.md`: the model in one page, the removed props with their replacements, and a
112
+ before/after per prop. Five apps have to read this.
113
+
114
+ ### Non-goals
115
+
116
+ - No consumer migration here. Five per-app WOs follow, each gated on its pin bump.
117
+ - **No compatibility shim** (operator decision): two models coexisting is the ambiguity that caused
118
+ this. One breaking version, clear error messages.
119
+ - No change to palettes, colours, series rendering, tooltips or interaction.
120
+ - Do not "improve" the estimate again. Under Rule 2 there is no estimate — bands are measured.
121
+
122
+ ### Risks
123
+
124
+ - **Four call sites lose responsive height** with `aspect` gone: `hram/AccessGapScatterPanel.jsx:182`
125
+ and three in `fitness-monitor/BodyHistoryPage.jsx`. fitness-monitor's `960/380` ratios were a
126
+ design decision, not an accident. **Each converts to a size token as a named, visible decision in
127
+ its own migration WO** — not silently. Charts still reflow horizontally (the wrapper stays
128
+ `width: 100%`); only the height stops tracking width. Operator accepted this trade on 2026-08-22.
129
+ - **The invariant test cannot run on estimates in jsdom.** `UCM-CHART-11` established that jsdom's
130
+ `getComputedTextLength` throws, so the unit tests must inject a measurement stub — and a
131
+ **browser-level check is therefore mandatory, not optional**, or the invariant is only asserted
132
+ against a fiction.
133
+ - **hram has several WOs in flight touching these files** (`RES-28`, `HRAM-RES-29`, `FIX-19`,
134
+ `CHT-3`). hram's migration WO must sequence after them, and this WO must not start hram's sweep.
135
+ - A `3.0.0` major is the first in this package's history. Check `publish.yml`'s version gate handles a
136
+ major, and that no consumer pins with a caret that would silently adopt it.
137
+
138
+ ### Tests to WRITE
139
+
140
+ - The two invariants, per preset, over the label matrix — the centrepiece.
141
+ - Empty tick labels → zero-height band.
142
+ - `size` token → height mapping, stable and theme-derived.
143
+ - `height` escape overrides the token.
144
+ - Each removed prop raises the dev-mode error naming its replacement.
145
+ - `ChartFrame`'s `minHeight` floor still behaves as `UCM-CHART-9` fixed it — that test stays green
146
+ unchanged.
147
+
148
+ **Browser-level check before publish**, on hram with real data at 1280 px and one narrow width: the
149
+ invariant holds on rendered geometry, and the Access scatter's blank band is gone. DOM measurement is
150
+ acceptable and must be declared; here it is sufficient, because the acceptance is arithmetic.
151
+
152
+ ---
153
+
154
+ ## Part B — Implementation map
155
+
156
+ ### Context package
157
+
158
+ **Named files to change**
159
+
160
+ - `src/components/charts/chartDefaults.js` — the resolver lives here today as three separate,
161
+ interacting functions. **Delete** `resolveChartHeight` (:385-394), `warnOnHeightMismatch`
162
+ (:408-421), and `spaceForRotatedTicks` (:229-238) plus its private helper `rotatedTickMetrics`
163
+ (:183-210) — but **keep** the text-measurement primitives they call: `measureTickTextWidthPx`
164
+ (:120-131, real DOM measurement via a hidden shared SVG `<text>` node — this is the "measured, not
165
+ estimated" plumbing Rule 2 needs) and `estimateTextWidthPx`/`glyphWidthEm` (:71-86, the no-DOM
166
+ fallback — SSR / jsdom without an injected stub). Also keep `sizeYAxisForContent` (:311-348, THEME-9,
167
+ y-axis width from tick content — the new resolver's `yAxisBand` should call this, not reinvent it),
168
+ `withGridDefaults`, `withAxisDefaults`, `withChartSlotDefaults`, `defaultNumericTickFormatter`,
169
+ `PACKAGE_DEFAULT_MARGIN`/`withMarginDefaults` (margins are now fully owned by the resolver per Rule
170
+ 3 — decide whether `withMarginDefaults` survives as an internal helper the resolver calls, or is
171
+ folded into it; either is fine, it must not stay a separate exported knob a consumer can pass into).
172
+ Add the new `resolveChartLayout` export here.
173
+ - `src/components/charts/BarChart.jsx` (:35-98), `LineChart.jsx` (:52-123), `ScatterChart.jsx`
174
+ (:173-289), all four accept `{ minHeight, height, aspect, margin }` in their destructuring and call
175
+ `resolveChartHeight`/`warnOnHeightMismatch`/`spaceForRotatedTicks` — every one of those call sites
176
+ is replaced by a single `resolveChartLayout(...)` call. `xAxisAngle` is not currently a named prop on
177
+ any of the three (rotation today comes in via `tickLabelStyle.angle` on a caller-supplied `xAxis`
178
+ entry, see `rotatedTickMetrics`'s `axis.tickLabelStyle?.angle`) — introducing `xLabels: "auto" |
179
+ "horizontal" | "angled"` as a first-class prop is new surface, not a rename; decide how `"angled"`
180
+ maps onto what MUI needs (`tickLabelStyle.angle` + the axis height the resolver computes) and how
181
+ `"auto"` decides using the *measured* label widths against `plotWidth` (Rule 3) — this is the one
182
+ piece of real algorithmic design in this WO, budget time for it.
183
+ - `TimeSeriesChart.jsx` (:269, :286) passes `minHeight={CHART_HEIGHT}` to `ChartFrame` (stays — that's
184
+ the frame floor, Rule/DoD says `ChartFrame`'s `minHeight` survives) and `height={CHART_HEIGHT}` to the
185
+ inner `BarChart` (:286, must migrate to `size="standard"` or equivalent — decide the token that
186
+ reproduces 320px, see size-token note below).
187
+ - `src/components/charts/ChartFrame.jsx` (:28-49, :112-124) — **unchanged behaviourally**: keeps
188
+ `minHeight`/`height`/`aspect` as its own card-floor props (DoD: "`ChartFrame` keeps its `minHeight`
189
+ as a card floor"). It currently also imports `warnOnHeightMismatch` (:14, :62-65) for its own
190
+ mismatch note — that function is deleted, so `ChartFrame` needs either an inline replacement of the
191
+ same dev-mode console warning (same text/condition, just no longer imported from `chartDefaults`) or
192
+ a decision that the note is no longer worth keeping now that the presets' own version of it is gone
193
+ — **do not delete `ChartFrame`'s warning silently**; if dropping it, say so in `CHART-LAYOUT.md`.
194
+ - New file `docs/CHART-LAYOUT.md` — the model in one page, each removed prop paired with its
195
+ replacement and a before/after code snippet. Look at `docs/` (if it exists) or the package README for
196
+ the house doc style/tone to match; five apps' implementers read this cold.
197
+ - Tests: `tests/chartDefaults.test.js` (imports the now-deleted functions at :17-26 — every one of
198
+ those imports must be replaced) and `tests/ChartFrame.test.jsx` (the `minHeight` floor regression
199
+ test named in the DoD must stay green, unchanged in intent).
200
+
201
+ **The invariant, concretely**
202
+
203
+ ```
204
+ chartHeight === plotHeight + xAxisBand + xTitleBand + legendBand
205
+ chartWidth === yAxisBand + plotWidth + rightPad
206
+ ```
207
+
208
+ `resolveChartLayout` must return every term on the right as its own named field (not just the totals)
209
+ so the test can sum them and assert equality against the height/width it also returns — a test that
210
+ can only compare final totals cannot catch a term double-counted against another (which is exactly
211
+ what `UCM-CHART-10` was). `xAxisBand` comes from `axis.height` (today's `MUI_LABELLED_X_AXIS_HEIGHT` +
212
+ `rotatedTickMetrics`'s extra, now unconditional on `xLabels`, not just on caller-supplied
213
+ `tickLabelStyle.angle`), `yAxisBand` from `sizeYAxisForContent`'s width, `legendBand` from whether
214
+ `hideLegend`/`legendPosition` puts a legend on the vertical axis (0 when legend is off or horizontal —
215
+ this band existed nowhere before this WO; MUI's own legend today just grows the SVG uncounted), `plot`
216
+ is whatever remains. Empty tick labels (Rule 2 / DoD, the Access-scatter case) must resolve
217
+ `xAxisBand`/`yAxisBand` to 0, not the MUI/package floor — trace `longestFormattedTick` (:271-284)
218
+ returning `''` through to the band, since today's `rotatedTickMetrics` floors width at `fontSize`
219
+ (:193-194) rather than letting empty collapse to zero.
220
+
221
+ **Size tokens**
222
+
223
+ `theme.spacing` is MUI's 8px unit (`src/theme/tokens.js:182`). `TimeSeriesChart.jsx`'s existing
224
+ `CHART_HEIGHT = 320` (its own comment at :18-33 explains *why* a fixed height exists — read it, the
225
+ reason still applies) is the one deployed number every current consumer has already implicitly agreed
226
+ looks right; make `"standard"` resolve to exactly that (`theme.spacing(40)`) so this migration doesn't
227
+ also silently redraw every existing default-sized chart. Pick `"compact"`/`"tall"` proportionally
228
+ (e.g. `theme.spacing(30)` / `theme.spacing(50)`) and document the exact numbers in `CHART-LAYOUT.md` —
229
+ these are visible product decisions, not internal plumbing, so state them plainly rather than burying
230
+ them in the resolver.
231
+
232
+ **Removed-prop dev-mode error**
233
+
234
+ Two existing precedents pull in different directions: `warnOnHeightMismatch` (being deleted) was a
235
+ `console.warn`, gated on `process.env.NODE_ENV !== 'production'`; `TimeSeriesChart.jsx:195`
236
+ (`hasSecondaryAxis && !secondaryYAxisLabel`) is a real `throw new Error(...)`, ungated, in production
237
+ too. The DoD text ("a dev-mode error naming the replacement, not a silent ignore") reads as: gate on
238
+ `NODE_ENV !== 'production'` like the deleted warning did (a shared package must not crash a
239
+ consumer's production page over a layout prop), but make it a `throw`, not a `console.warn`, inside
240
+ that gate — loud in dev, inert in prod. Apply it to `minHeight`/`aspect`/`margin` on the four presets;
241
+ each error message names the specific replacement (`size`/removed-with-no-replacement/removed
242
+ respectively — `margin` has no successor prop at all, the model owns it completely, say that plainly
243
+ in the message).
244
+
245
+ **Do-not-touch / invariants that must survive unchanged**
246
+
247
+ - Palette, series colours, tooltips, legend *content*, interaction — none of this WO's concern (Non-
248
+ goals). Only the geometry that decides *how much room* things get.
249
+ - `sizeYAxisForContent`'s existing behaviour and its own tests (:311-348) — reuse, do not rewrite.
250
+ - `ChartFrame`'s `minHeight` floor test (`UCM-CHART-9`'s regression) — must stay green, unmodified in
251
+ intent, even though its `warnOnHeightMismatch` import needs to change.
252
+ - `ScatterChart.jsx` keeps its own y-axis-only sizing rationale (:213-223, the `data.map(point => y)`
253
+ adapter) — this WO's `resolveChartLayout` change must not silently break that adapter's contract.
254
+
255
+ **Known pitfall**
256
+
257
+ `UCM-CHART-11`'s own register note: jsdom's `getComputedTextLength` throws / is unimplemented, so
258
+ `measureTickTextWidthPx` returns `null` under test and every unit test that needs a real number must
259
+ inject a measurement stub (the existing `measureTextWidth` injection point in `spaceForRotatedTicks`
260
+ is the pattern — carry the same injectability into `resolveChartLayout`). This is *why* the WO also
261
+ requires a **mandatory browser-level check** before publish (see Verification, Part C) — the unit
262
+ suite alone cannot prove the invariant holds against real rendered text.
263
+
264
+ Directive: work from this package; open only the named files to verify. If you need the full
265
+ `docs/` directory listing or README style, or need to search for other `chartDefaults` imports across
266
+ the package (there may be call sites in `src/index.js` exports or `dev/` harness files not listed
267
+ above), delegate that lookup to a read-only Explore sub-agent rather than open-ended browsing.
268
+
269
+ ### Target repo working directory (absolute)
270
+
271
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`
272
+
273
+ ### Preamble — REQUIRED, do not dispatch without it
274
+
275
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
276
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`,
277
+ > and this repo's `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/
278
+ > deps/schema/CI unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` —
279
+ > the register row and the review verdicts are the orchestrator's alone.** Do NOT `git add`/`commit`/
280
+ > `push` — leave every change uncommitted in the working tree for the orchestrator's independent
281
+ > review. WRITE the tests the "Tests to WRITE" section (Part A) calls for AND **RUN the tests you just
282
+ > wrote** to confirm they execute and pass — that is the ONLY test run you do (NOT the app's
283
+ > affected/full suite, NOT any review, NOT the mandatory browser-level check — that is the
284
+ > orchestrator's, after you finish). The orchestrator re-runs the authoritative set, does the
285
+ > independent review, and drives the browser-level check after you finish — those are the gate; your
286
+ > own run does not count as it.
287
+ >
288
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
289
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
290
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
291
+ > `RESULT: DONE|BLOCKED <reason>`.
292
+
293
+ ---
294
+
295
+ ## Part C — Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
296
+
297
+ > **If you are the implementer reading this work order as your own specification: STOP at this line.
298
+ > Everything below describes what the Orchestrator does AFTER you finish. You do none of it — no
299
+ > reviewers, no verification run, no register edit, no commit.** You ARE the invocation described
300
+ > below; do NOT shell out to `codex exec`.
301
+
302
+ ### Execution directive
303
+
304
+ Implement through `codex exec` in the background, invoked directly via Bash (never the
305
+ `debugger`/`*_coder` Agent wrappers), with BOTH `--skip-git-repo-check` and
306
+ `--dangerously-bypass-approvals-and-sandbox`. `cwd` = this repo's root (never `…/webapps`). Fallback
307
+ to direct Claude implementation only on Codex quota/rate-limit/non-zero exit — check
308
+ `.claude/codex-status.md` first per `AGENTS.md`; the fallback flips authorship, making an independent
309
+ `reviewer` mandatory regardless of the routing below.
310
+
311
+ ### Review routing
312
+
313
+ Tier 3, shared-core, **breaking**: `reviewer` AND `ui_reviewer` both run, full context, concurrently
314
+ in one background batch (not sequential, not after commit) — model Sonnet for `reviewer` given Tier 3.
315
+ The Orchestrator additionally runs its own targeted pass for: the invariant test's actual assertion
316
+ strength (does it sum the named bands, or only compare totals — the thing that would let a
317
+ `UCM-CHART-10`-shaped bug back in), whether the dev-mode error truly stays inert in production, and
318
+ whether `ChartFrame`'s dropped/kept mismatch warning was a silent behaviour change.
319
+
320
+ ### Verification
321
+
322
+ 1. The affected-areas test gate: `tests/chartDefaults.test.js`, `tests/ChartFrame.test.jsx`, and any
323
+ new chart-preset test files, via the package's test runner (`pnpm test` scoped to these files/dirs
324
+ — check `package.json`'s test script for the right invocation, do not run the full suite unless it
325
+ turns out these are the only test files in the package).
326
+ 2. **Mandatory browser-level check, not optional** (Part A "Tests to WRITE" + Risks): render on hram
327
+ with real data at 1280px and one narrow width (375px), confirm the invariant holds against actually
328
+ rendered geometry and that the Access-scatter blank band is gone. DOM measurement is acceptable
329
+ here and must be declared as such in the register Notiz (per AGENTS.md → Reviews, prototype-style
330
+ substitution disclosure) — this WO's own text calls the browser check "sufficient... because the
331
+ acceptance is arithmetic," so a declared DOM-measurement substitution meets the gate; a screenshot
332
+ is not required, but the specific measured numbers must be reported, not just "looks fine."
333
+ 3. Check `publish.yml`'s version gate (`.github/workflows/publish.yml:72-96`, a plain per-segment
334
+ integer `greaterThan` compare) — it already handles a major bump correctly (`3.0.0 > 2.42.3`), no
335
+ change needed there. Bump `package.json`'s `"version"` (:3, currently `2.42.3`) to `3.0.0` as part
336
+ of this diff — the publish workflow is push-triggered off `main` and gates only on the version
337
+ string increasing, so the version bump IS the publish trigger; do not push before everything else
338
+ here is green. Per the Risk note, do NOT chase down consumer caret pins in this WO — that is each
339
+ consumer's own migration WO's problem (Non-goals: no consumer migration here).
340
+
341
+ ### Register + commit
342
+
343
+ `WORK_ORDERS.md` row `UCM-CHART-12`: `done`, both reviewers named with verdict, the browser-level
344
+ check's declared method (DOM measurement or real screenshots) and its numbers, commit SHA. Single
345
+ concise commit subject, e.g. `UCM-CHART-12: one layout model for charts (breaking, 3.0.0)`. Push to
346
+ `main` (this repo's trunk — no `develop` step, per the Envelope header). This is a **major, breaking,
347
+ shared-core** publish: after push, confirm in the Actions log that `publish.yml` actually ran and
348
+ published `3.0.0` — a silent publish failure here is invisible to every consumer's future pin bump.