@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 +4 -3
- package/src/hooks/useFormValues.js +13 -1
- package/src/math-tokens.css +83 -69
- package/src/utils/submitForm.js +53 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.10.
|
|
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/
|
|
48
|
-
"@uniweb/
|
|
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
|
|
201
|
+
if (isUpload(control)) {
|
|
190
202
|
for (const file of [].concat(value).filter(isFile)) {
|
|
191
203
|
files.push({ file, field: control.path })
|
|
192
204
|
}
|
package/src/math-tokens.css
CHANGED
|
@@ -5,85 +5,99 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @import "@uniweb/kit/math-tokens.css";
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/* ───
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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)"
|
|
87
|
+
"(1)" ends up glued to the equation.
|
|
73
88
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
display formula to CSS block layout would left-align the lot
|
|
82
|
-
is what centres them
|
|
83
|
-
`display: block math` and stays centred. Where
|
|
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
|
-
|
package/src/utils/submitForm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`, {
|