@uniweb/kit 0.10.22 → 0.10.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.10.22",
3
+ "version": "0.10.24",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,9 +43,10 @@
43
43
  "fuse.js": "^7.0.0",
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
+ "temml": "^0.13.2",
46
47
  "@uniweb/core": "0.8.2",
47
- "@uniweb/semantic-parser": "1.2.1",
48
- "@uniweb/scene": "0.1.3"
48
+ "@uniweb/scene": "0.1.3",
49
+ "@uniweb/semantic-parser": "1.2.1"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "react": "^19.0.0",
@@ -170,6 +170,18 @@ function setIn(node, [key, ...rest], value) {
170
170
  return { ...base, [key]: setIn(base[key], rest, value) }
171
171
  }
172
172
 
173
+ /**
174
+ * Which control kinds hold uploads.
175
+ *
176
+ * BOTH `file` and `image` — they are two words in the authoring vocabulary for
177
+ * the same control, and the visual editor draws a file picker for either. Only
178
+ * `file` was checked here, so an `image` control's `File` objects went into
179
+ * `formData` and `JSON.stringify` turned each into `{}`: an attachment the
180
+ * visitor chose, reported as sent, arriving empty. Exactly the failure the
181
+ * split below exists to prevent, reachable through the other spelling.
182
+ */
183
+ const isUpload = (control) => control.type === 'file' || control.type === 'image'
184
+
173
185
  /**
174
186
  * Split the held values into what is submitted and what is uploaded.
175
187
  *
@@ -186,7 +198,7 @@ function split(controls, values) {
186
198
  const value = valueAt(values, control.path)
187
199
  if (value === undefined) continue
188
200
 
189
- if (control.type === 'file') {
201
+ if (isUpload(control)) {
190
202
  for (const file of [].concat(value).filter(isFile)) {
191
203
  files.push({ file, field: control.path })
192
204
  }
@@ -5,85 +5,99 @@
5
5
  *
6
6
  * @import "@uniweb/kit/math-tokens.css";
7
7
  *
8
- * `prose-tokens.css` already imports this, so a foundation using that needs
9
- * nothing further. This file exists separately because rendering math and
10
- * setting up prose typography are different decisions: the academic template
11
- * has 23 equations and no prose column, and while these rules lived inside
12
- * prose-tokens it shipped its maths unstyled matrices with their rows
13
- * touching, `=` signs out of line, no equation numbers with nothing to point
14
- * at. Correct metrics are not a typography opt-in.
8
+ * That single import is the whole contract: it pulls Temml's own stylesheet AND
9
+ * the two corrections below. `prose-tokens.css` does NOT pull it in a
10
+ * foundation that renders equations imports both, and prose-tokens' own header
11
+ * says so. (`framework/_contracts/math-css-parity.test.js` fails if any kit
12
+ * stylesheet imports a sibling.) Rendering math and setting up prose typography
13
+ * are different decisions: the academic template has 23 equations and no prose
14
+ * column, and while these rules lived inside prose-tokens it shipped its maths
15
+ * unstyled. Correct metrics are not a typography opt-in.
15
16
  *
16
- * press emits the same declarations into its EPUB and Paged.js output, since
17
- * those lanes carry their own stylesheets and cannot import this one. The two
18
- * copies are pinned by `framework/_contracts/math-css-parity.test.js`.
17
+ * ── Why the @import, and why it is not a copy ──
18
+ *
19
+ * Temml renders LaTeX to MathML and expects its own stylesheet to be present.
20
+ * That sheet is not decoration: `<menclose>` is NOT a MathML-Core element, it is
21
+ * Temml's polyfill, and the rules that draw `\cancel`, `\overline` and `\sout`
22
+ * over it live only there. Without them those constructs render as the bare
23
+ * term — a formula that means something different from what the author wrote.
24
+ * It also carries the `@font-face` for `Temml.woff2` and the per-engine
25
+ * `@supports` arms for accents and stretchy glyphs.
26
+ *
27
+ * The font is load-bearing for more ordinary content than it looks. Two rules
28
+ * ask for it: `math .mathscr` (`\mathscr`, a clone of KaTeX_Script remapped onto
29
+ * Unicode 1D49C–1D4B5) and `mo.tml-prime` — so every `f'` and `f''` depends on
30
+ * it for vertical alignment. Note it is NOT what renders `\mathcal`: that goes
31
+ * through `*.mathcal { font-feature-settings: 'ss01' }`, a NotoSans stylistic
32
+ * set, and is unaffected by the font's presence.
33
+ *
34
+ * It is imported rather than vendored because the bundler resolves the font URL
35
+ * for us — Vite emits `Temml.woff2` as an asset and rewrites the `src`. A text
36
+ * copy breaks that, and drifts from the Temml that produced the markup.
37
+ *
38
+ * ⚠️ `temml` is therefore a DIRECT dependency of kit, for the same reason
39
+ * `shiki` and `fuse.js` are: kit is bundled into a foundation by that
40
+ * foundation's Vite build, so kit's own imports must resolve from the
41
+ * foundation's `node_modules`. Only this stylesheet and the font reach a bundle;
42
+ * kit never imports Temml's JS.
43
+ *
44
+ * ── Why only two rules below ──
45
+ *
46
+ * This file used to carry eight, and six of them were Temml's own restated —
47
+ * lifted individually, without the sheet that scopes them, which is what caused
48
+ * the gap this import closes. Restating them was worse than redundant:
49
+ *
50
+ * .tml-right / .tml-left Temml declares these TWICE — plain, then
51
+ * `text-align: -webkit-right` inside a Chromium
52
+ * `@supports` arm. An unconditional copy landing
53
+ * last defeated the arm in the browser it was for.
54
+ * .tml-sml-pad Temml scopes it to non-Firefox; ours did not.
55
+ * mtable.tml-jot mtd Temml has it, same values, plus a Firefox variant.
56
+ * .tml-eqn::before Temml has it, and `body { counter-reset }` too.
57
+ *
58
+ * Anything Temml already states correctly is now simply Temml's. What is left is
59
+ * what Temml genuinely does not have.
19
60
  */
61
+ @import "temml/dist/Temml-Local.css";
20
62
 
21
- /* ─── Math (Temml MathML) ────────────────────────────────────────────────────
22
- Browsers render MathML natively, so it is easy to conclude math needs no CSS.
23
- It needs these, for two separate reasons.
63
+ /* ─── Correction 1: the mtd padding Temml assumes but does not set ──────────
64
+ Temml's sheet says "Default mtd top padding is 0.5ex per MathML-Core and
65
+ user-agent CSS" and only *adjusts* it for jot and small. Measured in Chrome
66
+ (2026-07), a pristine `mtd` gets 0px. A `pmatrix` or `cases` carries no
67
+ `tml-*` class at all, so nothing else covers it and its rows sit touching.
24
68
 
25
- Temml emits class hooks and expects a stylesheet to define them: `tml-left`
26
- and `tml-right` carry the `&` column alignment of an `aligned` environment,
27
- `tml-jot` its extra row spacing. Undefined, a derivation's `=` signs drift
28
- out of line and its rows sit flush. Those three declarations are Temml's own.
69
+ ⚠️ Guarded to exclude Firefox, and that is load-bearing rather than cautious.
70
+ Temml zeroes `mtd` padding deliberately inside `@-moz-document url-prefix()`
71
+ ("Adjust Firefox spacing between array rows"). An unguarded `math mtd` rule
72
+ outranks it on specificity (0,0,2 vs 0,0,1) regardless of the at-rule, so it
73
+ would silently undo that tuning in the one engine Temml had tuned. This is
74
+ the same mistake as the `.tml-right` copy above, which is why it is stated
75
+ here instead of being learned twice.
29
76
 
30
- The `math mtd` rule is not. Temml's stylesheet only *adjusts* row spacing for
31
- jot, on the stated assumption that mtd already gets 0.5ex of vertical padding
32
- "per MathML-Core and user-agent CSS" — but a pristine mtd measures 0px in
33
- Chrome (2026-07). A pmatrix or cases carries no tml-* class at all, so
34
- Temml's stylesheet alone fixes the derivation and leaves the matrix touching.
35
-
36
- These live here rather than in their own opt-in file because correct math
37
- metrics are not a design choice the way callout colours are — a site that
38
- sets its typography should not also have to know this.
39
-
40
- press ships the same declarations to the EPUB and Paged.js lanes, which have
41
- their own stylesheets and cannot import this one. That duplicate is pinned by
42
- `framework/_contracts/math-css-parity.test.js`, not by trust. */
43
- .tml-right { text-align: right; }
44
- .tml-left { text-align: left; }
45
- .tml-sml-pad { padding-left: 0.05em; }
46
- math mtd { padding-top: 0.5ex; padding-bottom: 0.5ex; }
47
- math mtable.tml-jot mtd { padding-top: 0.7ex; padding-bottom: 0.7ex; }
48
-
49
- /* AMS auto-numbering, for lanes that keep our CSS counter.
50
-
51
- Scoped to :empty because the document lanes cannot rely on it -- Paged.js
52
- rewrites counters for its own pagination and strips counter-increment, so
53
- every equation rendered as "(0)" (measured 2026-07-31; the declaration
54
- survives intact without the polyfill). press therefore writes the numbers
55
- into the spans as text, and a span carrying a number is no longer :empty, so
56
- the two can never both fire.
57
-
58
- AMS auto-numbering. Which equations number is the AUTHOR's choice, made in
59
- LaTeX: `align` and `equation` number, `aligned` and the starred forms do not.
60
- Without these two rules that choice was discarded -- `align` and `align*`
61
- rendered identically, so an author who asked for numbers silently got none. */
62
- .tml-eqn:empty::before {
63
- counter-increment: tmlEqnNo;
64
- content: "(" counter(tmlEqnNo) ")";
65
- }
66
- body {
67
- counter-reset: tmlEqnNo;
77
+ `@supports (not (-moz-appearance: none))` is Temml's own idiom for
78
+ "everywhere but Firefox". */
79
+ @supports (not (-moz-appearance: none)) {
80
+ math mtd { padding-top: 0.5ex; padding-bottom: 0.5ex; }
68
81
  }
69
- /* Display math needs CSS block layout, not MathML layout, for an equation TAG
70
- to reach the right margin: the tag rides in an mtable whose `width:100%`
82
+
83
+ /* ─── Correction 2: let a numbered equation's TAG reach the margin ──────────
84
+ Display math needs CSS block layout, not MathML layout, for an equation tag
85
+ to sit at the right margin: the tag rides in an mtable whose `width: 100%`
71
86
  Chromium ignores under `display: block math`, collapsing the spacer cells so
72
- "(1)" sits glued to the equation instead of at the margin.
87
+ "(1)" ends up glued to the equation.
73
88
 
74
- `!important` is not decoration. Temml emits `style="display:block math"` on
75
- every display formula unconditionally no option turns it off — so an inline
76
- style beats any rule we write, including Temml's own `math.tml-display
77
- { display: block }`, which is exactly what this restores. Fixing it here
78
- rather than in the generator also repairs math that was already built.
89
+ Temml already declares `math.tml-display { display: block; width: 100% }`,
90
+ and it cannot win: Temml emits `style="display:block math"` inline on every
91
+ display formula unconditionally, with no option to turn it off, and an inline
92
+ style beats any stylesheet rule. So this restores Temml's own intent with the
93
+ `!important` its own rule lacks.
79
94
 
80
- Scoped with :has() to formulas that actually carry a tag. Switching every
81
- display formula to CSS block layout would left-align the lot -- MathML layout
82
- is what centres them -- so an unnumbered derivation or matrix keeps
83
- `display: block math` and stays centred. Where :has() is unsupported the tag
84
- simply does not reach the margin; nothing else changes. */
95
+ Scoped with `:has()` to formulas that actually carry a tag. Switching every
96
+ display formula to CSS block layout would left-align the lot MathML layout
97
+ is what centres them so an unnumbered derivation or matrix keeps
98
+ `display: block math` and stays centred. Where `:has()` is unsupported the
99
+ tag simply does not reach the margin; nothing else changes. */
85
100
  math.tml-display:has(.tml-eqn) {
86
101
  display: block !important;
87
102
  width: 100%;
88
103
  }
89
-
@@ -32,7 +32,10 @@
32
32
  * when omitted
33
33
  * @param {object} [args.context] — where the submission came from:
34
34
  * formId, sectionType, sectionId,
35
- * pageId, pageLabel
35
+ * pageId, pageLabel. `formId` is
36
+ * sent at the top level of the
37
+ * body; the rest ride in
38
+ * `metadata` (see below)
36
39
  * @param {string} [args.verificationToken] — bot-protection token, when the
37
40
  * endpoint verifies one
38
41
  * @param {Array<File|{file:File,field?:string}>} [args.files]
@@ -95,10 +98,20 @@ export async function submitForm({
95
98
  }))
96
99
  : fileSlots
97
100
 
101
+ // `formId` rides at the TOP LEVEL, not inside `metadata` with the rest of the
102
+ // context. It is the only part of a submission's origin an endpoint stores as
103
+ // its own field rather than in an opaque blob, because it is what submissions
104
+ // are grouped BY — every other origin key is decoration read back for display.
105
+ // Nesting it means the endpoint's own column is never filled, and nothing on
106
+ // either side reports that: the value is present, one level down, and the
107
+ // column is simply null forever.
108
+ const { formId, ...origin } = context || {}
109
+
98
110
  // ── API name → wire name. See the header before "correcting" these. ──
99
111
  const body = {
100
112
  formData,
101
- metadata: { ...context, preview: summary || deriveSummary(formData) },
113
+ ...(formId ? { formId } : {}),
114
+ metadata: { ...origin, preview: summary || deriveSummary(formData) },
102
115
  ...(verificationToken ? { turnstileToken: verificationToken } : {}),
103
116
  ...(Array.isArray(slots) && slots.length ? { fileSlots: slots } : {}),
104
117
  }
@@ -126,6 +139,35 @@ export async function submitForm({
126
139
  return { ...result, filesUploaded: entries.length, ...report }
127
140
  }
128
141
 
142
+ /**
143
+ * One entry of an endpoint's `uploadUrls`, as a URL.
144
+ *
145
+ * Two shapes are in the wild and both mean the same thing: a bare URL string,
146
+ * or a **record** describing the slot — `{slot, name, uploadUrl}` is what the
147
+ * endpoint this client is built against actually returns. Reading only the
148
+ * string form does not degrade, it *breaks*: a record is truthy, so it was used
149
+ * as the URL directly and `fetch` stringified it to `[object Object]`, turning
150
+ * every upload into a request for a path that cannot exist. The submission row
151
+ * was already written by then, so the visitor's message arrived and their files
152
+ * did not.
153
+ *
154
+ * Anything that does not yield a non-empty string returns `''`, so the caller
155
+ * falls back to the documented `{target}/upload` — which is where the bytes were
156
+ * going anyway in every deployment seen so far.
157
+ *
158
+ * @param {*} entry
159
+ * @returns {string}
160
+ */
161
+ function readUploadUrl(entry) {
162
+ if (typeof entry === 'string') return entry.trim()
163
+ if (entry && typeof entry === 'object') {
164
+ for (const key of ['uploadUrl', 'url', 'href']) {
165
+ if (typeof entry[key] === 'string' && entry[key].trim()) return entry[key].trim()
166
+ }
167
+ }
168
+ return ''
169
+ }
170
+
129
171
  /**
130
172
  * Accept either bare `File`s or `{ file, field }` pairs, and drop anything that
131
173
  * is not a file. The pair form exists so a submission can say WHICH field an
@@ -176,7 +218,7 @@ async function uploadFiles(entries, result, target, fetchFn) {
176
218
  const urls = Array.isArray(result?.uploadUrls) ? result.uploadUrls : []
177
219
 
178
220
  for (const [slot, { file }] of entries.entries()) {
179
- const url = urls[slot] || `${base}/upload`
221
+ const url = readUploadUrl(urls[slot]) || `${base}/upload`
180
222
  let res
181
223
  try {
182
224
  res = await fetchFn(url, {
@@ -208,11 +250,18 @@ async function uploadFiles(entries, result, target, fetchFn) {
208
250
  // count is what a quota or an invoice would otherwise derive from. Sending it
209
251
  // costs a few bytes and satisfies the stricter reading of the contract, in
210
252
  // which `files` is required and its absence is a malformed call.
211
- const manifest = entries.map(({ file }, slot) => ({
253
+ // Carries `field` for the same reason the create manifest does — which form
254
+ // control an attachment answers is the difference between a readable
255
+ // submission and two anonymous blobs. The two manifests describe the same
256
+ // files and now describe them with the same keys; a receiver that built its
257
+ // stored record from this one rather than from the create manifest would
258
+ // otherwise lose the association, silently and only for uploads.
259
+ const manifest = entries.map(({ file, field }, slot) => ({
212
260
  slot,
213
261
  name: file.name,
214
262
  size: file.size,
215
263
  mime: file.type || 'application/octet-stream',
264
+ ...(field ? { field } : {}),
216
265
  }))
217
266
 
218
267
  const done = await fetchFn(`${base}/finalize`, {