@stapel/listings-react 0.23.0 → 0.24.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 (89) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +56 -0
  3. package/dist/api/generated/schema.d.ts +119 -6
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/api/types.d.ts +46 -3
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/CardBadges.d.ts +47 -0
  9. package/dist/default/CardBadges.d.ts.map +1 -0
  10. package/dist/default/CardBadges.js +38 -0
  11. package/dist/default/CardBadges.js.map +1 -0
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +6 -6
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  16. package/dist/default/ListingDetailPane.js +12 -19
  17. package/dist/default/ListingDetailPane.js.map +1 -1
  18. package/dist/default/ListingPhoto.d.ts.map +1 -1
  19. package/dist/default/ListingPhoto.js +12 -8
  20. package/dist/default/ListingPhoto.js.map +1 -1
  21. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  22. package/dist/default/ListingSerpCard.js +3 -5
  23. package/dist/default/ListingSerpCard.js.map +1 -1
  24. package/dist/default/ListingSpecList.d.ts +74 -0
  25. package/dist/default/ListingSpecList.d.ts.map +1 -0
  26. package/dist/default/ListingSpecList.js +90 -0
  27. package/dist/default/ListingSpecList.js.map +1 -0
  28. package/dist/default/cardGallery.d.ts +81 -0
  29. package/dist/default/cardGallery.d.ts.map +1 -0
  30. package/dist/default/cardGallery.js +266 -0
  31. package/dist/default/cardGallery.js.map +1 -0
  32. package/dist/default/index.d.ts +6 -0
  33. package/dist/default/index.d.ts.map +1 -1
  34. package/dist/default/index.js +5 -0
  35. package/dist/default/index.js.map +1 -1
  36. package/dist/i18n/es.d.ts.map +1 -1
  37. package/dist/i18n/es.js +1 -0
  38. package/dist/i18n/es.js.map +1 -1
  39. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  40. package/dist/i18n/generated/errors.es.gen.js +1 -1
  41. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  42. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  43. package/dist/i18n/generated/errors.gen.js +3 -0
  44. package/dist/i18n/generated/errors.gen.js.map +1 -1
  45. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  46. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  47. package/dist/i18n/ru.d.ts.map +1 -1
  48. package/dist/i18n/ru.js +3 -0
  49. package/dist/i18n/ru.js.map +1 -1
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/model/cardBadges.d.ts +134 -0
  55. package/dist/model/cardBadges.d.ts.map +1 -0
  56. package/dist/model/cardBadges.js +131 -0
  57. package/dist/model/cardBadges.js.map +1 -0
  58. package/dist/model/featureText.d.ts +63 -0
  59. package/dist/model/featureText.d.ts.map +1 -0
  60. package/dist/model/featureText.js +133 -0
  61. package/dist/model/featureText.js.map +1 -0
  62. package/dist/model/features.d.ts.map +1 -1
  63. package/dist/model/features.js +59 -0
  64. package/dist/model/features.js.map +1 -1
  65. package/llms.txt +5 -2
  66. package/manifest.json +47 -1
  67. package/nav-manifest.json +1 -1
  68. package/package.json +5 -5
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/generated/schema.ts +119 -6
  71. package/src/api/types.ts +55 -5
  72. package/src/default/CardBadges.tsx +105 -0
  73. package/src/default/ListingCard.tsx +7 -21
  74. package/src/default/ListingDetailPane.tsx +13 -37
  75. package/src/default/ListingPhoto.tsx +54 -26
  76. package/src/default/ListingSerpCard.tsx +10 -28
  77. package/src/default/ListingSpecList.tsx +195 -0
  78. package/src/default/cardGallery.ts +287 -0
  79. package/src/default/index.ts +22 -0
  80. package/src/i18n/es.ts +3 -0
  81. package/src/i18n/generated/errors.es.gen.ts +1 -1
  82. package/src/i18n/generated/errors.gen.ts +3 -0
  83. package/src/i18n/generated/errors.json +9 -0
  84. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  85. package/src/i18n/ru.ts +5 -0
  86. package/src/index.ts +15 -0
  87. package/src/model/cardBadges.ts +237 -0
  88. package/src/model/featureText.ts +199 -0
  89. package/src/model/features.ts +60 -0
package/src/index.ts CHANGED
@@ -166,6 +166,21 @@ export {
166
166
  unreadableFeatureCount,
167
167
  } from "./model/features.js";
168
168
  export type { FeatureCopySource } from "./model/features.js";
169
+ export { featureUnit, formatSpecValue } from "./model/featureText.js";
170
+
171
+ // ── model: the card badge contract (stapel-listings 0.21.3) ──────────────────
172
+ export {
173
+ badgePresentation,
174
+ badgeValueText,
175
+ cardBadgeText,
176
+ cardBadgeTexts,
177
+ hasCardBadgeContract,
178
+ } from "./model/cardBadges.js";
179
+ export type {
180
+ CardBadgeContract,
181
+ CardBadgePresentation,
182
+ CardBadgeRow,
183
+ } from "./model/cardBadges.js";
169
184
 
170
185
  // ── model: the engagement axis (already-seen / view count / the overlay) ─────
171
186
  export {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The CARD BADGE CONTRACT — what a badge on a result card is allowed to say.
3
+ *
4
+ * ── What was on screen ────────────────────────────────────────────────────
5
+ *
6
+ * A live card's badge line read "Brick · 3 · 9". Three true facts about a
7
+ * flat, and two of them unreadable: 3 what, 9 what. The projection carried
8
+ * the VALUES and the display half printed them, because that is all a stored
9
+ * `features_badges` row could offer — a number with no unit and no question
10
+ * beside it.
11
+ *
12
+ * stapel-listings 0.21.3 adds the missing half to each element:
13
+ *
14
+ * `value` the raw answer
15
+ * `label` its resolved copy, where the answer is an option
16
+ * `unit` what the number is measured in
17
+ * `name` the question — the feature's own display name
18
+ * `presentation` which of those to print, decided by the SERVER
19
+ *
20
+ * `presentation` is the load-bearing key, and it is the server's decision
21
+ * rather than a rule guessed here, because the right badge for a value
22
+ * depends on the CATEGORY: "3 rooms" wants its name, "Brick" is a
23
+ * boolean whose name IS the badge, "20 000 km" wants its unit and no name at
24
+ * all, and no client-side heuristic gets all three right for every catalogue
25
+ * in the fleet.
26
+ *
27
+ * ── The four presentations ───────────────────────────────────────────────
28
+ *
29
+ * `value` the value alone — "Brick" (an option label)
30
+ * `value_unit` value and unit — "20 000 km"
31
+ * `name_value` name and value — "Floor 3": a SPACE and never a colon,
32
+ * because a card is a caption and not a
33
+ * form. The unit is kept when the
34
+ * element carries one — a named row
35
+ * that dropped it reads "Mileage 20 000"
36
+ * `name` the name alone — a TRUE boolean, whose whole content
37
+ * is that the flat is brick. A FALSE
38
+ * one renders nothing: "not brick" is
39
+ * not a selling point, and a card is a
40
+ * summary rather than a form.
41
+ *
42
+ * ── Why a local type extension and not a regenerated schema ───────────────
43
+ *
44
+ * The generated `FeatureDao` union is already unusable on this side (the
45
+ * discriminator defect, `api/types.ts`), `features_badges` is a `JSONField`
46
+ * on the serializer, and this pair reads the row through
47
+ * {@link ListingFeatureDao} — a hand-written mirror of the runtime shape —
48
+ * for exactly that reason. The contract is an additive extension of the same
49
+ * mirror, so a regeneration cannot take it away and an older backend that
50
+ * sends none of the five keys is simply a row where
51
+ * {@link hasCardBadgeContract} is false.
52
+ *
53
+ * ── The fallback is the whole compatibility story ─────────────────────────
54
+ *
55
+ * A row with no `presentation` is a row from a server that predates the
56
+ * contract, and it is rendered exactly as it is rendered today — through
57
+ * `@stapel/attributes-react`'s `<FeatureBadges>`, off the stored DAO's own
58
+ * config. Nothing about this module is required for a card to draw.
59
+ */
60
+ import type { ListingCardBadgeElement, ListingFeatureDao } from "../api/types.js";
61
+
62
+ /**
63
+ * Which parts of a badge element the server asked to be printed.
64
+ *
65
+ * Read off the GENERATED element rather than spelled again here: the four
66
+ * readings are the server's enum, and a fifth added upstream must turn this
67
+ * build red instead of falling through {@link badgePresentation}'s default in
68
+ * silence.
69
+ */
70
+ export type CardBadgePresentation = ListingCardBadgeElement["presentation"];
71
+
72
+ /**
73
+ * The keys stapel-listings 0.21.3 adds to a `features_badges` /
74
+ * `features_title` element.
75
+ *
76
+ * The NAMES and their types come from the generated element; the optionality
77
+ * is this pair's own, and is the whole compatibility story — the contract
78
+ * makes them required, and a server that predates it sends none of them. Same
79
+ * argument as {@link ListingEngagementFields}, one file over: a generated type
80
+ * is a promise about the contract, not about the bytes a deployment sends.
81
+ */
82
+ export type CardBadgeContract = {
83
+ /** The raw answer. Absent on a redacted row, which is why the renderers
84
+ * below all tolerate it, and `unknown` because the DAO union types it per
85
+ * feature type and a badge prints whatever it was given. */
86
+ readonly value?: unknown;
87
+ } & {
88
+ readonly [K in "label" | "unit" | "name"]?: ListingCardBadgeElement[K] | null;
89
+ } & {
90
+ /** Widened back to `string` on purpose: {@link badgePresentation} exists to
91
+ * REFUSE a reading it does not know, and a field typed as the four would
92
+ * make that refusal unreachable — and untestable — while a server is still
93
+ * free to send a fifth. */
94
+ readonly presentation?: string | null;
95
+ };
96
+
97
+ /** A stored badge row, with the contract's keys where the server sends them. */
98
+ export type CardBadgeRow = ListingFeatureDao & CardBadgeContract;
99
+
100
+ /**
101
+ * `presentation` as one of the four, or `undefined`.
102
+ *
103
+ * An unrecognised spelling is `undefined` rather than a guess: the whole
104
+ * point of the key is that the SERVER decided, and a client that invented a
105
+ * fifth reading would print one category's rule on another category's card.
106
+ */
107
+ export function badgePresentation(row: CardBadgeRow): CardBadgePresentation | undefined {
108
+ const raw = row.presentation;
109
+ if (typeof raw !== "string") return undefined;
110
+ switch (raw) {
111
+ case "value":
112
+ case "value_unit":
113
+ case "name_value":
114
+ case "name":
115
+ return raw;
116
+ default:
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Does this projection speak the contract at all?
123
+ *
124
+ * ONE row declaring a `presentation` is enough: the server writes the whole
125
+ * list in one pass, so a mixed list means a row the server deliberately left
126
+ * plain, and that row still renders (as its value alone) rather than
127
+ * dragging the other four back to the old path.
128
+ */
129
+ export function hasCardBadgeContract(rows: readonly CardBadgeRow[]): boolean {
130
+ return rows.some((row) => badgePresentation(row) !== undefined);
131
+ }
132
+
133
+ /** Trimmed copy, or `""` — `null` is the shape the wire uses for absent. */
134
+ function text(value: string | null | undefined): string {
135
+ return typeof value === "string" ? value.trim() : "";
136
+ }
137
+
138
+ /**
139
+ * How many decimals the wire actually stated.
140
+ *
141
+ * A card badge must not invent precision and must not lose it: the server
142
+ * sends "2.0" for an engine volume and `2` for a number of rooms, and JS
143
+ * cannot tell `2.0` from `2` once it is a number — so a STRING value keeps
144
+ * the decimals it was written with and a number keeps its own.
145
+ */
146
+ function statedDecimals(raw: unknown): number {
147
+ const written = typeof raw === "string" ? raw : String(raw);
148
+ const tail = written.split(".")[1];
149
+ return tail === undefined ? 0 : tail.replace(/[^0-9]/g, "").length;
150
+ }
151
+
152
+ /**
153
+ * The element's answer, as a person reads it: the server's `label` when there
154
+ * is one, the value with its digits grouped when it is a number, the value
155
+ * verbatim otherwise.
156
+ */
157
+ export function badgeValueText(row: CardBadgeRow, locale?: string): string {
158
+ const label = text(row.label);
159
+ if (label.length > 0) return label;
160
+ const raw = row.value;
161
+ if (raw === null || raw === undefined || raw === "") return "";
162
+ if (typeof raw === "boolean") return "";
163
+ const parsed = typeof raw === "number" ? raw : Number(raw);
164
+ if (typeof raw !== "object" && Number.isFinite(parsed)) {
165
+ const digits = statedDecimals(raw);
166
+ try {
167
+ return new Intl.NumberFormat(locale, {
168
+ minimumFractionDigits: digits,
169
+ maximumFractionDigits: digits,
170
+ }).format(parsed);
171
+ } catch {
172
+ return parsed.toFixed(digits);
173
+ }
174
+ }
175
+ return typeof raw === "object" ? "" : String(raw);
176
+ }
177
+
178
+ /** Is this element's value the `true` a `name`-presented boolean needs? */
179
+ function isTrue(raw: unknown): boolean {
180
+ return raw === true || raw === 1 || raw === "true";
181
+ }
182
+
183
+ /**
184
+ * One badge element → the text a card prints, or `undefined` for an element
185
+ * that has nothing to say (a false boolean, a blank value).
186
+ *
187
+ * The one place the four presentations are read. Every card surface calls
188
+ * this and none of them re-derives it, for the same reason `CardTarget`
189
+ * exists: three cards each re-deciding what a badge says is three places for
190
+ * "Brick · 3 · 9" to come back.
191
+ */
192
+ export function cardBadgeText(row: CardBadgeRow, locale?: string): string | undefined {
193
+ const presentation = badgePresentation(row);
194
+ const name = text(row.name);
195
+ const unit = text(row.unit);
196
+
197
+ if (presentation === "name") {
198
+ // The name IS the badge, and only while the answer is yes.
199
+ return isTrue(row.value) && name.length > 0 ? name : undefined;
200
+ }
201
+
202
+ const value = badgeValueText(row, locale);
203
+ if (value.length === 0) return undefined;
204
+
205
+ const withUnit = unit.length > 0 ? `${value} ${unit}` : value;
206
+
207
+ switch (presentation) {
208
+ case "value_unit":
209
+ return withUnit;
210
+ case "name_value":
211
+ // A SPACE, not a colon. "Floor 3" is a caption; "Floor: 3" is a form
212
+ // field, and a card is neither a form nor a table — the colon is the
213
+ // punctuation the spec list uses because a spec list IS the table.
214
+ // The unit rides along: a named row that dropped it reads "Mileage
215
+ // 20 000", which is the defect this contract exists to close.
216
+ return name.length > 0 ? `${name} ${withUnit}` : withUnit;
217
+ default:
218
+ // `value`, and an element the server left plain in a list that speaks
219
+ // the contract. The value alone, never the unit — the server said so.
220
+ return value;
221
+ }
222
+ }
223
+
224
+ /** Every element that has something to say, in the server's order. */
225
+ export function cardBadgeTexts(
226
+ rows: readonly CardBadgeRow[],
227
+ locale?: string
228
+ ): readonly { readonly slug: string; readonly text: string }[] {
229
+ const out: { slug: string; text: string }[] = [];
230
+ for (const row of rows) {
231
+ const printed = cardBadgeText(row, locale);
232
+ if (printed !== undefined && typeof row.slug === "string" && row.slug.length > 0) {
233
+ out.push({ slug: row.slug, text: printed });
234
+ }
235
+ }
236
+ return out;
237
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A stored feature value as a person READS it — with its unit, and with its
3
+ * digits grouped the way the reader's locale groups them.
4
+ *
5
+ * ── What was on screen ────────────────────────────────────────────────────
6
+ *
7
+ * Measured on the live listing page: "Power 173", "Mileage 20000", "Engine
8
+ * volume 2.0". Three defects in three rows:
9
+ *
10
+ * - **no unit.** 173 what. 20000 what. A spec row whose value is a bare
11
+ * number is a row a buyer has to guess at, and mileage is the single
12
+ * number a used-car buyer decides on;
13
+ * - **no digit grouping.** `20000` is read digit by digit; "20 000" is read
14
+ * at a glance. `formatFeatureValue`'s integer branch is `String(value)`
15
+ * (attributes-react `src/format.ts:196`), deliberately — it mirrors the
16
+ * engine's own `str(value)` — and the engine renders for a machine-read
17
+ * API, not for a spec table;
18
+ * - **the wrong decimal mark.** `2.0` in a Russian storefront. The engine
19
+ * writes `toFixed`, which is invariant by construction.
20
+ *
21
+ * ── Where the unit comes from ─────────────────────────────────────────────
22
+ *
23
+ * There is NO generic `unit` key anywhere in this fleet's feature contract —
24
+ * not on `FeatureDef` (`stapel-attributes/base.py:154-208`), not on
25
+ * `IntConfig`/`FloatConfig` (`attributes-react/src/generated/featureDef.ts:163`),
26
+ * not on the stored DAO. The unit of an `int`/`float` IS its `postfix`, free
27
+ * text on the type's config, with `postfix1000` as the abbreviated unit the
28
+ * engine switches to at a thousand. `convertible_unit` is the one type with
29
+ * real unit semantics (`unitType`/`unit_m`/`unit_i`), and its own formatter
30
+ * already appends the resolved code — so this module leaves it alone.
31
+ *
32
+ * So "render the unit from the definition" means: read `postfix`. It reaches
33
+ * a display surface two ways, and `model/features.ts` now tries both — the
34
+ * stored row's own config first (`dto_to_dao` copies `postfix` at write
35
+ * time), then the CATEGORY's definition of the same slug, which is the path
36
+ * that repairs every listing published before a catalogue gained its units.
37
+ *
38
+ * ── Why the whole formatter is not delegated ──────────────────────────────
39
+ *
40
+ * `formatFeatureValue` is the fleet's one formatter and stays it: every type
41
+ * but `int` and `float` goes straight through it, untouched. The two numeric
42
+ * types are intercepted because the change is not a different ANSWER, it is
43
+ * the same answer typeset — same value, same precision rule, same
44
+ * `postfix1000` switch at a thousand, same translated unit, with the digits
45
+ * run through `Intl.NumberFormat` instead of `String()`.
46
+ */
47
+ import type { FeatureDef, FeatureValueDto, FormatOptions } from "@stapel/attributes-react";
48
+ import { featureConfig, featureType, formatFeatureValue } from "@stapel/attributes-react";
49
+
50
+ /** The engine's own defaults (`types/int/type.py`, `types/float/type.py`). */
51
+ const DEFAULT_DIGITS: Readonly<Record<"int" | "float", number>> = {
52
+ int: 1,
53
+ float: 2,
54
+ };
55
+
56
+ function str(value: unknown): string {
57
+ return typeof value === "string" ? value : "";
58
+ }
59
+
60
+ /**
61
+ * A config string the engine declares to be a translation key, resolved.
62
+ *
63
+ * `prefix`/`postfix`/`postfix1000` are collected by upstream's
64
+ * `get_translation_keys` unconditionally, so they go through the host's
65
+ * catalogue exactly as `formatFeatureValue` sends them — a Russian storefront
66
+ * was reading "L" and "each" off its own spec table before that landed, and
67
+ * this path must not reintroduce it.
68
+ */
69
+ function translated(options: FormatOptions | undefined, raw: unknown): string {
70
+ const text = str(raw);
71
+ if (text.length === 0) return "";
72
+ const t = options?.t;
73
+ if (t === undefined) return text;
74
+ const resolved = t(text);
75
+ return resolved.length > 0 ? resolved : text;
76
+ }
77
+
78
+ /**
79
+ * The unit a numeric feature is measured in, as the reader sees it — or `""`.
80
+ *
81
+ * `postfix1000` when the engine's own thousand switch applies, `postfix`
82
+ * otherwise, so the unit and the number can never disagree about scale.
83
+ */
84
+ export function featureUnit(
85
+ feature: FeatureDef,
86
+ dto: FeatureValueDto | undefined,
87
+ options?: FormatOptions
88
+ ): string {
89
+ const type = featureType(feature);
90
+ if (type !== "int" && type !== "float") return "";
91
+ const config = featureConfig(feature);
92
+ const parsed = Number(dto?.value);
93
+ const big = str(config["postfix1000"]).length > 0 && Number.isFinite(parsed) && Math.abs(parsed) >= 1000;
94
+ return translated(options, config[big ? "postfix1000" : "postfix"]);
95
+ }
96
+
97
+ /**
98
+ * `Intl.NumberFormat` for one value, or `undefined` when the runtime has no
99
+ * `Intl` for this locale to offer.
100
+ *
101
+ * A bad BCP-47 tag throws `RangeError`, and a spec table that renders nothing
102
+ * because a host passed `"en_US"` would be a worse outcome than an ungrouped
103
+ * number — so the caller falls back rather than the page failing.
104
+ */
105
+ function grouped(value: number, digits: number, locale: string | undefined): string | undefined {
106
+ try {
107
+ return new Intl.NumberFormat(locale, {
108
+ minimumFractionDigits: digits,
109
+ maximumFractionDigits: digits,
110
+ }).format(value);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The one numeric row, typeset — see the module header for what each piece
118
+ * is repairing.
119
+ *
120
+ * Returns `undefined` for a value that is not a number, so the caller falls
121
+ * back to the shared formatter and an unreadable row keeps saying so.
122
+ */
123
+ function formatNumeric(
124
+ feature: FeatureDef,
125
+ dto: FeatureValueDto,
126
+ kind: "int" | "float",
127
+ options: FormatOptions | undefined
128
+ ): string | undefined {
129
+ // `Number(null)` is 0 and `Number("")` is 0, so a blank row would render as
130
+ // a confident zero. The shared formatter's own `isBlank` gate is upstream of
131
+ // this branch and must be restated here rather than assumed.
132
+ const raw = dto.value;
133
+ if (raw === null || raw === undefined || raw === "") return undefined;
134
+ const parsed = typeof raw === "number" ? raw : Number(raw);
135
+ if (!Number.isFinite(parsed)) return undefined;
136
+ const config = featureConfig(feature);
137
+ const precision = config["precision"];
138
+ const digits =
139
+ typeof precision === "number" && precision >= 0 ? precision : DEFAULT_DIGITS[kind];
140
+
141
+ const prefix = translated(options, config["prefix"]);
142
+ const postfix1000 = str(config["postfix1000"]);
143
+
144
+ // The engine switches unit AND scale at exactly a thousand
145
+ // (`format_value`: `value / 1000`, trailing zeros stripped, `postfix1000`
146
+ // as the unit). Grouping a scaled number is harmless and keeps one code
147
+ // path; the trailing-zero strip is the engine's, done on the plain digits
148
+ // before the locale ever sees them, because "1.50" and "1,5" are the same
149
+ // decision made in two places.
150
+ if (postfix1000.length > 0 && Math.abs(parsed) >= 1000) {
151
+ const scaled = Number((parsed / 1000).toFixed(digits));
152
+ const body =
153
+ grouped(scaled, decimalsOf(scaled, digits), options?.locale) ?? String(scaled);
154
+ return join(prefix, body, translated(options, postfix1000));
155
+ }
156
+
157
+ // `precision` drives ONLY the scaled branch of an int — the engine's plain
158
+ // branch is `str(value)`, and an integer never grows a decimal tail
159
+ // whatever the config says (D26: a live category shipped `precision: 1` on
160
+ // a year field and the card read "2024.0").
161
+ const value = kind === "int" ? Math.trunc(parsed) : parsed;
162
+ const fraction = kind === "int" ? 0 : digits;
163
+ const body = grouped(value, fraction, options?.locale) ?? value.toFixed(fraction);
164
+ return join(prefix, body, translated(options, config["postfix"]));
165
+ }
166
+
167
+ /** How many decimals a scaled value actually has, capped at the configured
168
+ * precision — the engine's `.rstrip('0').rstrip('.')`, expressed as a count
169
+ * so `Intl` can do the rendering. */
170
+ function decimalsOf(value: number, cap: number): number {
171
+ const tail = value.toFixed(cap).split(".")[1] ?? "";
172
+ return tail.replace(/0+$/, "").length;
173
+ }
174
+
175
+ /** `prefix` + body + unit, with the single space the engine puts between a
176
+ * number and its unit and no space at all where there is no unit. */
177
+ function join(prefix: string, body: string, unit: string): string {
178
+ return unit.length > 0 ? `${prefix}${body} ${unit}` : `${prefix}${body}`;
179
+ }
180
+
181
+ /**
182
+ * A stored value as a spec row prints it: `formatFeatureValue` for every type
183
+ * but the two numeric ones, which are typeset here instead.
184
+ *
185
+ * Signature-compatible with `formatFeatureValue` on purpose — a surface
186
+ * swaps one for the other and nothing else changes.
187
+ */
188
+ export function formatSpecValue(
189
+ feature: FeatureDef,
190
+ dto: FeatureValueDto | undefined,
191
+ options?: FormatOptions
192
+ ): string | undefined {
193
+ const type = featureType(feature);
194
+ if (dto !== undefined && (type === "int" || type === "float")) {
195
+ const typeset = formatNumeric(feature, dto, type, options);
196
+ if (typeset !== undefined) return typeset;
197
+ }
198
+ return formatFeatureValue(feature, dto, options);
199
+ }
@@ -317,6 +317,64 @@ function selectOptions(
317
317
  return [...table].map(([value, label]) => ({ value, label }));
318
318
  }
319
319
 
320
+ /**
321
+ * The config keys that carry a value's UNIT, in the order they belong to.
322
+ *
323
+ * There is no generic `unit` key anywhere in this contract — the unit of an
324
+ * `int`/`float` is its `postfix` (free text on the type's config, with
325
+ * `postfix1000` as the abbreviated unit the engine switches to at a
326
+ * thousand), and `convertible_unit` states its own with `unitType`/`unit_m`/
327
+ * `unit_i`. `prefix` rides along because it is the same fact on the other
328
+ * side of the number (a currency mark, a «~»).
329
+ *
330
+ * `precision` is deliberately NOT here. `dto_to_dao` writes it on every
331
+ * numeric row (defaulted, never absent), so it could only ever be adopted
332
+ * onto a row from some older server — where the stored value has already
333
+ * been ROUNDED to the precision it was written with, and re-rendering it at
334
+ * today's would print decimals the record does not contain.
335
+ */
336
+ const UNIT_KEYS: readonly string[] = [
337
+ "prefix",
338
+ "postfix",
339
+ "postfix1000",
340
+ "unitType",
341
+ "unit_m",
342
+ "unit_i",
343
+ ];
344
+
345
+ /**
346
+ * The unit the CATEGORY declares, for a stored row that carries none.
347
+ *
348
+ * Measured on the live listing page: "Power 173", "Mileage 20000" — bare
349
+ * numbers, in a deployment whose detail page holds the category's own feature
350
+ * defs and passes them in for exactly this kind of repair. `dto_to_dao`
351
+ * copies `postfix` at WRITE time, so a listing published before its category
352
+ * gained a unit keeps printing without one for the rest of its life, and so
353
+ * does every row written by a server that predates the key.
354
+ *
355
+ * Same rule as the option table two functions up, and for the same reason:
356
+ * the stored row wins wherever it said anything, because it is what the
357
+ * listing was published with, and the category fills only the silence. An
358
+ * empty string counts as silence — `dto_to_dao` writes `postfix=None` as
359
+ * absent, but a catalogue that once held `""` should not out-rank a
360
+ * catalogue that now holds "km".
361
+ */
362
+ function adoptedUnits(
363
+ config: Readonly<Record<string, unknown>>,
364
+ categoryDef: FeatureDef | undefined
365
+ ): Readonly<Record<string, string>> | undefined {
366
+ const declared = categoryDef?.config;
367
+ if (declared === undefined) return undefined;
368
+ const out: Record<string, string> = {};
369
+ for (const key of UNIT_KEYS) {
370
+ const stored = config[key];
371
+ if (typeof stored === "string" && stored.length > 0) continue;
372
+ const value = (declared as Readonly<Record<string, unknown>>)[key];
373
+ if (typeof value === "string" && value.length > 0) out[key] = value;
374
+ }
375
+ return Object.keys(out).length === 0 ? undefined : out;
376
+ }
377
+
320
378
  /**
321
379
  * The category's option TREE, adopted whole for a `hierarchical_select` that
322
380
  * stored none — see {@link TREE_VALUED}.
@@ -365,6 +423,8 @@ function featureView(
365
423
  const options =
366
424
  selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
367
425
  if (options !== undefined) config["options"] = options;
426
+ const units = adoptedUnits(config, categoryDef);
427
+ if (units !== undefined) Object.assign(config, units);
368
428
 
369
429
  const visibility = storedVisibility(dao.visibility);
370
430
  const feature: FeatureDef = {