@autobusal/common 1.36.2 → 1.37.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.
package/Button/Button.tsx CHANGED
@@ -5,6 +5,13 @@ interface Props {
5
5
  type?: 'button' | 'submit'
6
6
  size?: 'medium' | 'small'
7
7
  loading?: boolean
8
+ /*
9
+ * Claude - 2026-08-30: a button that cannot be disabled is a real gap, not
10
+ * a style choice - the screen that hit it (bulk send with nothing selected)
11
+ * had to fall back to a bespoke <button> to express it, which is how that
12
+ * screen ended up not looking like the rest of the dashboard.
13
+ */
14
+ disabled?: boolean
8
15
  active?: boolean
9
16
  subtype?: 'secondary' | 'delete'
10
17
  text: string | JSX.Element
@@ -19,7 +26,7 @@ interface Props {
19
26
  value?: string
20
27
  }
21
28
 
22
- const Button = ({ type, size, loading, active, subtype, text, noMargin, maxWidth, onClick, name, value }: Props) => (
29
+ const Button = ({ type, size, loading, disabled, active, subtype, text, noMargin, maxWidth, onClick, name, value }: Props) => (
23
30
  <ButtonStyled
24
31
  $size={ size }
25
32
  type={ loading ? 'button' : type }
@@ -27,6 +34,12 @@ const Button = ({ type, size, loading, active, subtype, text, noMargin, maxWidth
27
34
  $subtype={ subtype }
28
35
  $margin={ noMargin ? false : true }
29
36
  $maxWidth={ maxWidth }
37
+ /*
38
+ * A loading button is disabled too: it already refuses to submit (the
39
+ * type swap above), and without this the pointer still says "click me"
40
+ * while a request is in flight.
41
+ */
42
+ disabled={ disabled || loading }
30
43
  onClick={ onClick }
31
44
  name={ name }
32
45
  value={ value }
package/Button/styles.ts CHANGED
@@ -70,4 +70,14 @@ export const ButtonStyled = styled.button<ButtonType>`
70
70
  &:hover {
71
71
  box-shadow: 0 4px 15px ${ props => props.theme.primary.normal };
72
72
  }
73
+ /*
74
+ * Claude - 2026-08-30: a disabled button has to LOOK unavailable, or the
75
+ * only feedback is a click that does nothing. Faded rather than restyled,
76
+ * so it is still recognisably the same button in the same place.
77
+ */
78
+ &:disabled {
79
+ opacity: 0.5;
80
+ cursor: not-allowed;
81
+ }
82
+
73
83
  `;
@@ -1,6 +1,6 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { AiOutlineCheck, AiOutlineCloudDownload } from 'react-icons/ai';
3
- import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
3
+ import { BiEditAlt, BiEnvelope, BiMessageDetail, BiTransfer, BiTrash } from 'react-icons/bi';
4
4
  import { FaCity, FaTrash } from 'react-icons/fa';
5
5
  import { GiMoneyStack } from 'react-icons/gi';
6
6
  import { IoDocumentLockOutline } from 'react-icons/io5';
@@ -22,6 +22,25 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
22
22
  case 'delete':
23
23
  return <BiTrash title={ t('table.actions.delete', { ns: 'common' }) } />;
24
24
 
25
+ /*
26
+ * Claude - 2026-08-30: two verbs the operator-invitation list needs.
27
+ *
28
+ * Added to the shared vocabulary rather than drawn as bespoke buttons on
29
+ * that one screen: an action that looks like an action everywhere else is
30
+ * the entire point of this component, and the screen that prompted this
31
+ * had hand-rolled its own row controls precisely because these were
32
+ * missing.
33
+ *
34
+ * 'compose' is writing the text that will be sent; 'send' is sending it.
35
+ * Two steps, two icons - a single 'mail' for both would make the
36
+ * destructive-ish one (it really posts) look like the harmless one.
37
+ */
38
+ case 'compose':
39
+ return <BiMessageDetail title={ t('table.actions.custom.compose', { ns: 'common' }) } />;
40
+
41
+ case 'send':
42
+ return <BiEnvelope title={ t('table.actions.custom.send', { ns: 'common' }) } />;
43
+
25
44
  case 'login_as':
26
45
  return <RiLoginCircleLine title={ t('table.actions.custom.login_as', { ns: 'common' }) } />;
27
46
 
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.36.2",
3
+ "version": "1.37.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
- "main": "index.ts"
6
+ "main": "index.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ }
7
10
  }
package/CHANGELOG.md DELETED
@@ -1,777 +0,0 @@
1
- # Changelog
2
-
3
- ## 1.36.2 - 2026-08-29
4
-
5
- - Seat map: a taken seat is DISABLED, struck through and labelled "Seat N - taken", instead of relying on colour alone with a click that was silently swallowed (audit A3-08c).
6
-
7
- - Drive map: the road line is asked through obtapi's /routes/directions instead of calling OpenRouteService from the browser, which CORS blocked every time - and which shipped a metered third-party key in the bundle to pay for requests that could not succeed (audit A7-26). The hook now answers one feature or null, not a GeoJSON envelope.
8
-
9
- ## 1.36.0 (2026-08-29)
10
-
11
- - Editors offer Save & Close alongside Save & Stay, so correcting a record no longer bounces you back to its list every time (opt-in per page, editing only). The pair is driven from here (Viewer + Actions).
12
-
13
- ## 1.35.0 (2026-08-29)
14
-
15
- - RouteFeature's compact chip draws a real tooltip on hover AND on focus, so a tap reveals the amenity name; `title` alone was invisible on touch and slow on desktop.
16
- - Calendar treats an unparseable defaultValue as nothing chosen, and anchors an empty picker on its minDate - so a floored picker (the return leg) opens on the month being travelled rather than on today or on January.
17
-
18
- ## 1.34.1 (2026-08-29)
19
-
20
- - Meta's stale-tag cleanup can no longer remove a tag React owns: it now identifies leftovers by having existed before React rendered, not by comparing values (Sentry BUSMAGUS-7 - on a prerendered page the leftover and the live tag carry the same value, so the old comparison deleted React's own node and the next navigation crashed on removeChild).
21
-
22
- ## 1.34.0 (2026-08-29)
23
-
24
- - Calendar renders one month per page - the neighbouring months' days are blank padding now, so a grid no longer shows two different 1sts and 31sts.
25
- - Calendar takes `maxYears`, raising a single picker's ceiling above what its type allows (booking pickers stay at one year); Viewer forwards it on 'picker' rows.
26
- - New SearchableSelect: a <select> you can type into - accent-insensitive matching, group headings, inert disabled options, written for the operator's stop picker.
27
- - BackWithTitle takes `actions`, rendered as pill links at the far end of the title line.
28
- - Table row actions for 'cities' and 'stations'.
29
-
30
- ## 1.33.14 (2026-08-26)
31
-
32
- - ChooseSeat: seat-selection add-on mode - `optional` + `fee` props; unpicked state shows the free-auto note, a made choice gets a back-to-automatic control. Default behaviour (required free pick) unchanged.
33
-
34
- ## 1.33.13
35
-
36
- ### Fixed
37
-
38
- - **`RouteItem` no longer advertises "From 0.00"**: zero-fare rows are unpriced legs search never sells, so they no longer count toward the cheapest-of figure. (Audit RT-11b.)
39
-
40
- ## 1.33.12
41
-
42
- ### Added
43
-
44
- - **`DefaultLocaleGate`** - the mirror of `LocaleGate` for the UNPREFIXED public branch: a visitor whose stored language is not the brand default is redirected from `/help` to `/{lang}/help`, so the page shown always matches the address (and its canonical). Crawlers carry no localStorage and keep seeing the canonical default-language page. (Audit decision CMS-07c.)
45
-
46
- ## 1.33.11
47
-
48
- ### Fixed
49
-
50
- - **The "Add Funds" link in the Agents and Visitors lists 404'd.** It pointed at `/admin/funds/manage/:id`, a route that has never existed; the funds form lives at `/account/funds/manage/:id` (admin + financer). (Platform audit ADM-06g.)
51
-
52
- ## 1.33.9
53
-
54
- ### Fixed
55
-
56
- - **Every field built through `Viewer` was unnamed.** The label was a `<span>` beside an input with no id, so each one announced as a bare "edit text" and clicking a label did nothing - across every admin and account form in the app, not one screen. `Item` now mints a `useId`-prefixed id for single-control rows and the label is a real `<label htmlFor>`. Additive: no caller changes, and the styled rule set was empty either way.
57
-
58
- ### Added
59
-
60
- - `Table` and `Viewer` take an optional `confirm`, so a destructive action can ask a question specific to what it destroys rather than the generic one. Existing callers keep the generic question.
61
-
62
- ## 1.33.8
63
-
64
- ### Added
65
-
66
- - **`Table` headers can sort.** A header cell now takes an optional sort key and direction and renders as a control when it does, so a table backed by a paginated, server-sorted endpoint can drive it from the header rather than a separate control. Tables that pass no sort key are untouched and render exactly as before.
67
-
68
- ## 1.33.7
69
-
70
- ### Changed
71
-
72
- - **`Required` renders a real `<label>`, and takes an optional `htmlFor`.** It was a `styled.span` sitting beside the field it named, which is a label to a sighted reader and nothing at all to a screen reader - every field it introduced announced as a bare "edit text". Nothing in the stylesheets selects `label` and a flex item computes identically either way, so this is semantics only.
73
- - **`Password` and `Calendar` take an optional `id`.** A caller cannot associate a visible label with a field it has no way to name, so the shared inputs had to be able to accept one before the forms using them could be fixed.
74
- - `AiAssist/AiReviewModal` runs its three HTML panes through `sanitizeHtml`. Everything the model returns is HTML rendered into an admin's browser, and every other sink in the codebase sanitises; this one did not.
75
-
76
- ## 1.33.6
77
-
78
- ### Added
79
-
80
- - **New `Map/Tiles` component - the one place the basemap comes from.** The same `TileLayer` line was copy-pasted into `Drive.tsx`, `@autobusal/map`'s `Display.tsx` and magus's own `RouteMap.tsx`, with a fourth copy in the mobile app, all hardcoding `tile.openstreetmap.org`. OSM's Tile Usage Policy does not cover commercial or heavy application use, and those servers throttle or block by referer and volume without notice - so the exposure was never the bill, it was that reacting to a block meant editing three repos, publishing to npm, redeploying both frontends and shipping a mobile store release.
81
-
82
- The URL now comes from `/settings/get` (`map.tile_url`) rather than the build, so all four clients switch from one server env var. `null` - the state until a provider is chosen - means the built-in OpenStreetMap default, so this changes nothing for anyone on upgrade. Attribution is rendered by the component rather than travelling with the URL, since ODbL requires it whoever serves the tiles.
83
-
84
- ### Changed
85
-
86
- - `Drive.tsx` renders `<Tiles />` instead of its own `TileLayer`.
87
-
88
- ## 1.33.4
89
-
90
- ### Added
91
-
92
- - **New `LocalizedFields` component** - a generic per-language field editor generalising SeoOverride (fixed to title/description) and Faqs/Translations (fixed to question/answer) into one taking an arbitrary field list. A section with just one translatable field (a category name, a menu label) no longer needs its own bespoke ~120-line copy of the same editor to support more than English+Albanian.
93
-
94
- ## 1.33.3
95
-
96
- ### Added
97
-
98
- - **`RouteItem` shows a "from" price** next to the route name/code - the lowest fare across the route's own `prices`, using the row's own pre-formatted `adult_display` (same currency/decimal convention as everywhere else). Both `browse` and `view` modes get it; a route with no prices at all shows nothing, same as it always could.
99
-
100
- ## 1.33.2
101
-
102
- ### Added
103
-
104
- - **`Meta` now defaults `og:image`/`twitter:image` to the label's own homepage hero photo** (`{settings.url}/home/background-light.jpg`) when a page doesn't pass its own `image`. Most of the ~136 call sites never had a share-preview image at all, so a link pasted into a chat assistant or social post previewed with nothing. A page with something more specific (an operator's logo, a blog post's own photo) still passes its own `image` and wins - this only fills the gap.
105
-
106
- ## 1.33.1
107
-
108
- ### Added
109
-
110
- - **`Table` accepts an optional `onReorder(draggedId, targetId)` prop.** When set, every row becomes an HTML5 drag source/drop target (dims to 40% opacity while being dragged) and a completed drag reports the dragged row's id and the row it was dropped on - the caller decides what "reorder" means for its own data. Omitted by every existing table, so this is purely additive; first consumer is `@autobusal/admin-menu` 1.1.2.
111
-
112
- ## 1.33.0
113
-
114
- ### Fixed
115
-
116
- - **`Viewer`'s `type: 'component'` rows never actually rendered, anywhere.** `Data.tsx` returned `null` for any item whose `name` was `undefined` before the switch statement that handled `'component'` ever ran - and a component row never HAS a name, it isn't a registered form field. Every SectionHeading divider, every `AiFieldAssist` Generate/Improve/Translate trigger, and every description paragraph moved inside a form's own box (SMS/Flex, this cycle) had silently never rendered, in any tab that used one, despite compiling clean and sitting right there in the bundle. Moved the `'component'` case to an early return above the name guard instead of weakening the guard's condition, which would have broken TypeScript's narrowing of `item.name` to `string` for every other case below it.
117
- - **AI trigger buttons and the review modal rendered raw i18n keys** (`ai_assist.trigger.generate`) instead of translated text. `ai_assist.*` lives in the `common` namespace, but the `t` passed into `AiFieldAssist`/`AiReviewModal` defaults to whatever namespace the calling page's own `useTranslation()` uses (`normal`/`whitelabel`) - every call now passes `{ ns: 'common' }`.
118
- - **`AiReviewModal` showed a generic "Request failed with status code 422"** instead of the real provider error on a failed generate/translate/improve. Removed its local error state/display entirely - `apiClient`'s existing global response interceptor already toasts `error.response.data.message` on any 422, the same mechanism every other form in the app already relies on; a local `onError` reading react-query's plain `Error.message` could only ever see Axios's generic text, never the backend's.
119
-
120
- ## 1.32.0
121
-
122
- ### Added
123
-
124
- - **Drag & drop on `File`**, the shared upload control used everywhere in the app - drop a file directly onto it instead of always going through the native picker. The control's own footprint (a single 38px-tall inline button) is unchanged everywhere it is already used; only a border/background highlight appears while dragging. A dropped file is handed to the same hidden `<input>` react-hook-form already registered, via a real dispatched `change` event, so every existing caller's validation/onChange keeps working with no changes on their end.
125
-
126
- ### Fixed
127
-
128
- - **A checkbox row with a long notice no longer stretches sideways.** `.row-checkbox`'s label+input are laid out `flex-direction: row-reverse`, and the row's own notice/error text (a plain div) was sharing that same row - a short notice fit fine, but a longer one (two sentences, easily) stretched the row's WIDTH to fit on one line, visibly pushing the checkbox by however long the text happened to be. Every div child of a checkbox row now gets `flex-basis: 100%`, dropping straight onto its own full-width line; the label and input are unaffected.
129
-
130
- ## 1.31.0
131
-
132
- ### Added
133
-
134
- - **A `row-component` field spans the full row width**, same as `row-textarea-html` already did - what lets a Viewer form's own section headings (`SectionHeading`, admin-label's Content/styles.ts) sit as a full-width divider instead of sharing a row with whatever field lands beside them.
135
-
136
- ## 1.30.0
137
-
138
- ### Changed
139
-
140
- - **`Viewer`'s field grid stays at two columns on every screen width.** It
141
- used to widen to three columns at >=1300px - fine for a form of
142
- independent, unrelated fields, but a form built for a two-column rhythm
143
- (one field, a divider row, a related pair, a divider row, ...) reflows
144
- into three and starts mixing fields from different logical groups onto
145
- the same row, with nothing to say which group a given row belongs to.
146
- Affects every Viewer-based form in the app, not just admin-label's.
147
-
148
- ## 1.29.0
149
-
150
- ### Added
151
-
152
- - **`AiFieldAssist` + the AI review modal.** The one AI-review surface every
153
- content admin form shares - Generate/Improve/Translate triggers that open
154
- a modal where a draft is always reviewed (and can be hand-edited or
155
- revised on a follow-up instruction) before an explicit Insert writes it
156
- into the calling field. Renders nothing at all when no AI provider is
157
- enabled for the label - the visibility gate the AI plan calls for, with
158
- no separate flag to keep in sync.
159
- - **`Viewer`'s `'component'` field type accepts a function** of the form's
160
- own `setValue`, not just plain JSX - the only way a component embedded in
161
- the field list (AiFieldAssist writing a draft into a SIBLING field like
162
- content_en) can reach state Viewer has always kept internal. Existing
163
- callers passing plain JSX are unaffected.
164
-
165
- ## 1.28.0
166
-
167
- ### Added
168
-
169
- - **`Row` takes an optional `actions`**, overriding the table's list for that
170
- row. The table-level prop is one array for every row, which cannot express
171
- an action only some rows have earned - a ticket download belongs to a paid
172
- order and to no other. `Rows` falls back to the table's list, so every
173
- existing caller is unaffected.
174
- - **`print` and `invoice` row-action icons.** Distinct from the existing
175
- generic `download`.
176
-
177
-
178
- ## 1.27.5
179
-
180
- ### Added
181
-
182
- - **`placeholder` on a `select`** — a leading empty option, for "nothing
183
- chosen".
184
-
185
- A select whose value matches no option silently displays the first one and
186
- the browser submits it as though it had been picked. That is exactly how
187
- every profile whose owner never touched the country field was saved as
188
- **Afghanistan**: it sorts first, and nothing had ever been selected.
189
- Changing the default from `0` to `''` did not help on its own — neither
190
- value exists as an option, so the browser fell back either way.
191
-
192
- Rendered only when placeholder text is supplied, so a select with a real
193
- default (sex, status, a type) does not grow an empty row it never wanted.
194
-
195
- ## 1.27.4
196
-
197
- ### Fixed
198
-
199
- - **The calendar allowed a different set of days depending on the visitor's
200
- timezone.** Day cells are built with `createDate`, which is `Date.UTC(...)`;
201
- the selectable bounds were built with `new Date(y, m, d)` — *local*
202
- midnight. Those are not the same instant, so the boundary day fell on
203
- either side of the limit depending on where the visitor was sitting.
204
-
205
- The consequence on the money path: **same-day departures were not
206
- selectable west of Greenwich.** A visitor in New York or Los Angeles could
207
- not pick today, because local midnight there is *after* the cell's UTC
208
- midnight, so today's cell tested as below the minimum. Same-day selling was
209
- deliberately enabled — this had been quietly undoing it for those visitors,
210
- and it is invisible from Europe. Both sides are UTC now, so the comparison
211
- is exact and identical everywhere.
212
-
213
- Found while chasing an apparent off-by-one in the birth-date picker, which
214
- turned out to be this skew wearing a different hat.
215
-
216
- ## 1.27.3
217
-
218
- ### Fixed
219
-
220
- - **The birth-date picker offered today, which the API refuses.** obtapi
221
- validates with `before:today`, so the calendar was letting you choose an
222
- answer and then calling it wrong. It now stops at yesterday.
223
-
224
- The year steps back too when yesterday fell in the previous one: the
225
- maximum is built from `today.getFullYear() + limits.max.year`, so on 1
226
- January a month/day of 31 December would otherwise have resolved to the
227
- coming 31 December rather than the one just gone. Checked against 1
228
- January, 1 March and a leap year.
229
-
230
- ## 1.27.2
231
-
232
- ### Fixed
233
-
234
- - **A birth date no longer defaults to today.** `Calendar` pre-fills with
235
- today when given no value, which is right for every other use of it — a
236
- departure, a report range, a broadcast day — and wrong for exactly one:
237
- nobody is born today. On the profile-completion form it meant anybody who
238
- filled in the fields that *looked* empty saved a date of birth of today,
239
- silently, and that is the field a passenger's age is judged by when they
240
- book. Reproduced end to end: the row saved `dob = <today>`.
241
-
242
- `type="dob"` now starts empty so the field reads as unanswered. The picker
243
- still opens on the current month — `createDate('')` is an Invalid Date and
244
- the grid built from it renders as nothing — but that anchor is display
245
- only; what the form submits stays empty until a day is clicked, and
246
- obtapi's new `required|date_format:d/m/Y|before:today` says so if it is
247
- left that way.
248
-
249
-
250
- ## 1.27.1
251
-
252
- ### Removed
253
-
254
- - **Everything 1.27.0 added for multi-TLD** — `localeHref`, the `LocaleDomain`
255
- type, `Meta`'s domain-resolved canonical and hreflang, and
256
- `ChangeLanguage`'s cross-domain navigation. `Meta` is back to resolving both
257
- from `window.location.origin` plus the `/{locale}/` prefix.
258
-
259
- The product decision changed after the code landed: ccTLDs will **301 to
260
- `.com/{locale}/`** rather than be real per-country sites, so none of this
261
- had a caller and none of it ever would. A ccTLD's geo signal multiplies
262
- content that already exists, and a new ccTLD starts at zero authority while
263
- a subdirectory inherits the domain's on day one.
264
-
265
- **Do not downgrade to 1.26.1 to get this** — 1.27.0 is published, so
266
- `@latest` would reintroduce it. This version is 1.26.1's behaviour with a
267
- higher number.
268
-
269
- ## 1.26.1
270
-
271
- ### Fixed
272
-
273
- - **`Table` drew an empty "Options" column when a table had only
274
- top-of-table controls.** `create`, `search` and `extra` all live *above* the
275
- table — `Actions/Get` already returns null for each — but `Header` and
276
- `Rows` decided whether to draw the options column from `actions.length > 0`,
277
- which counted them. A table using the component purely for sorting, search
278
- and paging therefore got an "Options" heading with an empty cell under every
279
- row.
280
-
281
- Invisible on the admin screens, where every table has row actions anyway;
282
- visible the moment the public bus-company directory used it. The row/top
283
- split is one shared list now (`Table/rowActions.ts`) rather than a condition
284
- repeated in three files that could drift.
285
-
286
- ## 1.26.0
287
-
288
- ### Added
289
-
290
- - **`HandoffBanner`** — "You are managing *X* as its administrator", shown on a
291
- brand's own domain for the length of a brand handoff, with a **Finish**
292
- button that ends the session. Rendered by both apps' private layouts; renders
293
- nothing at all unless `/bff/session` reports a handoff, so an ordinary
294
- session costs one request. See `magus/BRAND_HANDOFF_PLAN.md`.
295
-
296
- Not the same component as magus's `ImpersonationBanner` and not driven by the
297
- same signal: that one reads `localStorage['impersonating']`, which page
298
- JavaScript sets and can therefore be wrong. This reads the BFF session, which
299
- is the only thing that actually knows. There is also nothing on this origin to
300
- *revert* to — the admin's own session is alive and untouched on a different
301
- origin — so the action is an explicit end, not a revert.
302
-
303
- - **`handoff` action on `Table`** — its own icon and label rather than borrowing
304
- `login_as`, for the same reason `reply` is not `approve`: `login_as` changes
305
- who you are *within* a brand, a handoff leaves for another brand's domain in a
306
- new tab.
307
-
308
- ### Notes
309
-
310
- - **There is deliberately no `pagehide`/`sendBeacon` revoke.** One was built, to
311
- shorten the window when somebody closes the tab, and testing removed it:
312
- `pagehide` with `persisted === false` also fires on a plain **page refresh**,
313
- so F5 revoked the token and dumped the admin on the logged-out screen.
314
- Measured — an identical reload driven by curl, with no beacon in play, keeps
315
- the session. No browser API distinguishes closing from reloading at that
316
- moment, so what remains is two mechanisms that both work (the Finish button
317
- and the two-hour token expiry) rather than three where one misfires.
318
-
319
- - The banner puts **Finish next to the message** rather than at the far edge.
320
- `space-between` reads better but the whitelabel admin's content column is a
321
- fixed 1491px that overflows the viewport at ordinary window sizes, which put
322
- the button off-screen at x=1856 — the one control that reliably ends the
323
- session cannot be the one you have to scroll sideways to find.
324
-
325
- ## 1.25.1
326
-
327
- **The SEO override wears the site's own form clothes.**
328
-
329
- `SeoOverride` declared its own `Field`/`Label`/`Input`/`Textarea` and restated the global field styling with *different* values — transparent instead of `inputs.background`, a primary-coloured border, its own font size — then sat on the bare page rather than a card. On a country or city screen that made the two SEO fields the only ones on screen not matching the form directly beneath them, which is the opposite of what its own comment claimed.
330
-
331
- It uses `.box` and `.row` now, the site's own card and field classes, with plain inputs so `GlobalStyles` reaches them exactly as it reaches every `Viewer` row. Being controlled is the only thing that genuinely differs from a Viewer row, and that is a state concern, not a visual one. The textarea keeps a single rule, for height: `GlobalStyles` gives every text input 38px, right for one line and useless for a description.
332
-
333
- Reaches the label SEO tab too, which had the same problem. Checked it does not land inside another card — `admin-label`'s `Options` is the tab strip, not a wrapper around the tab bodies.
334
-
335
- ## 1.25.0
336
-
337
- **New `setDepartureZone(zone)` — the date picker floors on today WHERE THE COACH LEAVES.**
338
-
339
- `setServerToday` was already an improvement on the buyer's device clock, but it is still the wrong clock: it is *our* date, in Tirana, and a coach leaving Bari has nothing to do with it. Only the departure city can say whether the 4th is still today for that journey.
340
-
341
- Falls back to `serverToday`, then to the device, so the bare search form (no journey chosen yet) behaves exactly as before. An unresolvable zone returns null and falls back too — a picker that refuses to open because a timezone lookup failed would be far worse than one flooring a day out.
342
-
343
- ## 1.24.0
344
-
345
- **`Policy` is now `Information`.**
346
-
347
- - `Policy` → `Information`, `useGetPolicy` → `useGetInformation`, and the endpoint behind it moves from `/api/routes/policy` to `/api/routes/information`. **Breaking**: update the import.
348
- - `RouteItem`'s `ReadPolicy` → `ReadInformation`, reading `route_item.information`.
349
- - Translation keys: `policy.title` → `information.title`, `route_item.policy` → `route_item.information`.
350
-
351
- The rename follows the Flexible Ticket model, where what a ticket permits comes from the add-on and the operator's own windows — not from this box.
352
-
353
- All notable changes to `@autobusal/common` are documented here. This project follows
354
- [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
355
-
356
- > Note: 1.10.0 through 1.15.1 were published without changelog entries. The
357
- > gap is left as-is rather than reconstructed after the fact.
358
-
359
- ## 1.23.0
360
-
361
- An unbookable date is inert, not merely faded.
362
-
363
- It was dimmed and otherwise fully interactive: the pointer cursor and the
364
- hover highlight still invited a click, and the click was answered with a
365
- browser alert saying the date was invalid. The calendar offered a day, let
366
- somebody reach for it, then told them off for taking the offer.
367
-
368
- `pointer-events: none` removes it from the conversation entirely — no cursor
369
- change, no hover, no click to refuse — alongside the `aria-disabled` and
370
- `tabIndex=-1` already on the element. Unreachable by mouse, keyboard and
371
- screen reader alike, and the alert is gone with it.
372
-
373
- ## 1.22.0
374
-
375
- The booking calendar floors on the SERVER's date, not the browser's.
376
-
377
- `new Date()` is the buyer's device clock, which says nothing about where they
378
- are travelling. Somebody in Shanghai at 06:00 on the 5th, looking at an
379
- Italian coach that does not leave until the evening of the 4th, had that day
380
- greyed out — while the server would have sold them the seat quite happily. The
381
- calendar was refusing a sale the API accepted.
382
-
383
- It also makes the picker agree with the date strip beside it, which has always
384
- been built server-side; the two used to disagree about which day came first.
385
-
386
- A date of birth keeps the local clock, because that one genuinely is about the
387
- person in front of the screen.
388
-
389
- ## 1.21.0
390
-
391
- `reply` joins the table actions with its own icon and label. The operator
392
- review page was borrowing `approve`, so the button on a review about them read
393
- "Approve" — which is the one thing they cannot do to it.
394
-
395
- ## 1.20.0
396
-
397
- `Rating` — stars, average and count, or nothing at all. Rendering nothing is
398
- the feature: obtapi withholds a score until there are enough published reviews
399
- to say something honest, so an absent rating has to read as "not saying yet"
400
- rather than as a zero. Anything filling the gap with empty stars would turn a
401
- new operator into a badly-rated one. Half stars are drawn, because rounding 3.5
402
- down loses the difference between middling and good — precisely the range where
403
- a reader is still deciding.
404
-
405
- ## 1.19.0
406
-
407
- `extra` joins `create` and `search` as a top-of-table slot rather than a
408
- per-row action. Any table opting into the extra slot also rendered one empty,
409
- iconless, still-clickable button in every row's options cell — Icon has no
410
- case for `extra`, so it drew nothing while taking the click. The refund queue
411
- has been doing this all along; the new review queue would have too.
412
-
413
- ## [1.18.0] - 2026-08-02
414
-
415
- ### Added
416
-
417
- - **`SeoOverride`** - a per-language editor for the SEO title/description
418
- overrides, shared by the label, country and city forms. One language is
419
- shown at a time (a brand writes copy for one or two, and thirteen sets of
420
- empty inputs invite nobody to fill any of them in) and the languages that
421
- already have copy are marked, so it is obvious which are done. Controlled
422
- and deliberately form-less: those pages submit every field in one request,
423
- so an editor with its own save would either post a partial update or leave
424
- two save buttons meaning different things on one screen.
425
-
426
- ## [1.17.0] - 2026-08-02
427
-
428
- ### Added
429
-
430
- - **Locale-prefixed URLs.** `localiseRoutes()` mirrors an absolute-path route
431
- tree under `/:locale`, and `LocaleGate` guards that branch - rejecting a
432
- prefix that is not one of the brand's languages, redirecting the default
433
- language back to its unprefixed home so only one URL is canonical, and
434
- switching i18n to whatever the URL asks for. `splitLocale`/`withLocale`/
435
- `localeFromPath` are the shared, router-free definition of a localised
436
- path, so the crawler and the sitemap agree with the components.
437
- - **hreflang alternates on every page.** Emitted from `Meta`, which already
438
- sits on ~136 call sites, so every real page gains them at once and a new
439
- page cannot forget them. `x-default` points at the unprefixed URL.
440
- Suppressed for noindex pages and whenever a caller passes an explicit
441
- canonical, which the path-derived alternates would contradict.
442
-
443
- ### Changed
444
-
445
- - **`ChangeLanguage` navigates** instead of only swapping the rendered text -
446
- behind an explicit `localised` prop, so the account and admin areas (which
447
- are deliberately not locale-prefixed) keep switching in place rather than
448
- navigating to a 404.
449
- - **The canonical drops a trailing slash.** Apache serves a prerendered page
450
- from a directory and 301s `/contact` to `/contact/`, so the same page used
451
- to advertise whichever form the visitor happened to arrive on. Harmless
452
- when it was the only path-derived tag; not harmless now that the alternates
453
- come from the same value, because one page would announce two URL sets.
454
-
455
- ## [1.16.0] - 2026-08-01
456
-
457
- ### Added
458
-
459
- - **The date field can tell its form when the day changes.** `ViewData.onChange`
460
- was already honoured by `select` and `checkbox`, but not by `date`/`dob`/
461
- `picker`, so a form whose other fields depend on the chosen day had no way
462
- to observe it short of not using `Viewer` at all. `Calendar` now takes an
463
- optional `onChange(DD/MM/YYYY)` and `Viewer` passes `item.onChange` through
464
- to it. Purely additive - existing callers are unaffected.
465
-
466
- ## [1.15.6] - 2026-08-01
467
-
468
- ### Changed
469
-
470
- - **An admin sees the real site while maintenance is on.** Deliberately
471
- admin only - an operator or agent signing in still gets the holding page,
472
- because the site is not open to them either. Presentation, not
473
- protection: obtapi still decides what any account may do.
474
- - `Calendar` takes an optional `minDate`, which can only RAISE the floor
475
- the type already sets. Used by the return leg so it cannot start before
476
- the outbound date.
477
-
478
- ### Fixed
479
-
480
- - `Required` rendered a fragment, so in the column-flex form rows the label
481
- and its asterisk became two flex items and the asterisk dropped onto its
482
- own line.
483
-
484
- ## [1.15.5] - 2026-08-01
485
-
486
- ### Added
487
-
488
- - `Required` - marks a form label as mandatory. The asterisk is
489
- `aria-hidden`, since a screen reader announcing "star" tells nobody
490
- anything; the input's own validation carries the semantics.
491
-
492
- ## [1.15.4] - 2026-08-01
493
-
494
- ### Fixed
495
-
496
- - **The language dropdown opened upward over the navbar.** It positioned
497
- itself with `bottom: calc((-1 * languages * 27px) - 50px - 8px)`, guessing
498
- where its own bottom edge should land by multiplying an assumed 27px row
499
- height by the language count. The rows are not 27px, so the error grew
500
- with every language a brand enabled - at thirteen it was far enough out to
501
- open over the page instead of below the trigger. Now `top: 100%`, which
502
- needs no arithmetic and cannot drift, plus a height cap so a brand with
503
- twenty languages does not run off the screen.
504
- - **`RouteItem` showed the wrong arrival country.** The city read
505
- `locations[lastLocation]` but the country read `locations[1]`. Identical
506
- on a two-stop route, wrong on anything longer: Tirana-Veria-Thessaloniki
507
- rendered "Thessaloniki (alb)", because `locations[1]` is Elbasan.
508
- - Country codes render upper case here too, matching the search results.
509
-
510
- ## [1.15.3] - 2026-08-01
511
-
512
- ### Changed
513
-
514
- - **Language switcher shows just the language name**, left-aligned, with
515
- the panel title left-aligned too. The "Change to" prefix on every row
516
- said nothing the panel's own title and the flag beside it were not
517
- already saying. One component serves both the public header and the
518
- panel sidebar, so both are fixed.
519
- - **The route map fits the trip** instead of sitting at a fixed zoom 6
520
- centred on the first stop. Every route drew the same view of the Balkans
521
- and none of them filled it, so following a route meant panning.
522
-
523
- ### Fixed
524
-
525
- - **`getCoordinates` sent every interior stop twice** - a four-stop route
526
- went out as A,B,B,C,C,D,D,D, with the last point repeated against
527
- itself. Directions APIs reject consecutive identical coordinates, so the
528
- request failed and the map fell back to bare markers with no road line.
529
- - **`useGetDriveData` cached on a bare `['drive-data']` key**, with the
530
- coordinates absent - so every route on the site shared one itinerary and
531
- the second route page you opened drew the first one's road.
532
- - Stops with no coordinates are dropped rather than plotted at 0,0, which
533
- put a pin off the west coast of Africa and dragged the fitted view
534
- across the Atlantic.
535
- - The query is skipped entirely when no `VITE_OPEN_ROUTE_SERVICE_KEY` is
536
- configured, instead of a guaranteed 401 on every route page view.
537
- - A stray `}` inside the `ButtonChange` styled-component template.
538
-
539
- ## [1.15.2] - 2026-08-01
540
-
541
- ### Added
542
-
543
- - `RouteFeature` takes an optional `compact` prop: icon only, with the name
544
- as a hover tooltip and a help cursor to advertise that the hover does
545
- something. Opt-in, because the browse and route-view pages have the room
546
- and their labels earn it - only the search results card asks for this.
547
-
548
- ### Fixed
549
-
550
- - `RouteFeature`'s image had **no `alt` at all**. That mattered already; it
551
- matters more in compact mode, where the image is the only content and a
552
- screen reader would otherwise announce nothing whatsoever per amenity.
553
-
554
- ## [1.9.0] - 2026-07-31
555
-
556
- ### Added
557
-
558
- - **`Viewer`/`Actions`/`Button` support a second submit-type action** via an
559
- optional `onTest` prop on `Viewer` and a new `'test'` entry in its
560
- `actions` array. A form with two submit buttons can't otherwise tell
561
- which one triggered submission - `Button` now forwards a native
562
- `name`/`value` pair, and `Viewer`'s `onSubmit` reads the submit event's
563
- `submitter` to route to `onTest` instead of `onSave` when the 'test'
564
- button was clicked, while keeping the exact same current form values.
565
- First consumer: `@autobusal/admin-label` 1.10.0's "Test" button on the
566
- SMTP settings tab.
567
-
568
- ## [1.8.3] - 2026-07-31
569
-
570
- ### Fixed
571
-
572
- - **`Meta.tsx`'s `useSoleMetaOwnership` no longer runs on every render.**
573
- It fired on every render of every `<Meta>` usage (136 call sites, no
574
- dependency array) and directly removed `<head>` children via raw DOM
575
- `.remove()` calls each time. The duplicate-tag problem it exists for -
576
- React's `createRoot` not reconciling against tags baked into the
577
- prerendered static HTML - can only exist before React's first render;
578
- every render after that, `<title>`/`<meta>`/`<link>` in `<head>` are
579
- exclusively React 19's own hoisting mechanism's doing, and it already
580
- correctly swaps them out as components mount/unmount with no help
581
- needed. Re-running this raw removal on every navigation instead raced
582
- that hoisting: it could delete a node mid-transition that React's own
583
- reconciler still held a reference to, and the next time React tried to
584
- remove or replace that now-detached node, `parentNode` was null and it
585
- threw an uncaught `TypeError: Cannot read properties of null (reading
586
- 'removeChild')` - not render-phase, so no `ErrorBoundary` catches it,
587
- silently aborting whatever navigation was in flight (URL updates,
588
- page never re-renders). This is the actual root cause of the frozen-
589
- navigation bug worked around piecemeal in `@autobusal/providers`
590
- 1.6.6-1.6.8, `@autobusal/hooks` 1.3.1, and `@autobusal/auth`
591
- 1.5.2-1.5.4 - all of which targeted `GoogleReCaptchaProvider` as a
592
- contributing trigger, but the crash reproduced identically with that
593
- provider removed entirely, on ~any route rendering `<Meta>`. Now
594
- scoped to a module-level flag so the cleanup runs exactly once, on the
595
- app's first `<Meta>` mount - it does its one real job (wiping stale
596
- prerendered tags before first paint) and never touches the DOM again.
597
-
598
- ## [1.8.2] - 2026-07-30
599
-
600
- ### Fixed
601
-
602
- - **`Meta.tsx` duplicate description/canonical/OG/Twitter tags.** The
603
- existing title-only dedup (`useSoleTitleOwnership`, since 1.5.1) is now
604
- `useSoleMetaOwnership` and covers every tag Meta renders, not just
605
- `<title>`. Surfaced by the new build-time prerender step: React doesn't
606
- reconcile against tags already sitting in `<head>` from a source other
607
- than its own current render (a prerendered snapshot's baked-in
608
- description, a leftover from a pre-locale-detection render, ...), so
609
- multiple `<meta name="description">`/`og:description`/etc could coexist
610
- - crawlers commonly read the FIRST occurrence, which could be the stale
611
- one, not the page's real content.
612
-
613
- ## [1.8.1] - 2026-07-30
614
-
615
- ### Fixed
616
-
617
- - **Added `loading="lazy" decoding="async"` to below-the-fold list images**
618
- (`BlogItem/Image.tsx` preview/short thumbnails, `CompanyItem.tsx`,
619
- `RouteItem.tsx` operator logos) - none had lazy loading, so a search
620
- results page or operator/agent listing loaded every logo eagerly
621
- regardless of position. `BlogItem/Image.tsx`'s `type="view"` (the single
622
- article's own hero image, likely that page's LCP element) instead gets
623
- `loading="eager" fetchPriority="high"`.
624
-
625
- ## [1.8.0] - 2026-07-30
626
-
627
- ### Added
628
-
629
- - **GA4 SPA pageview tracking in `Meta`.** gtag.js only auto-sends a
630
- page_view on its initial `config` call - every client-side route change
631
- was invisible to analytics (pairs with `@autobusal/providers` 1.6.2, which
632
- disables the automatic one). Meta now sends an explicit pageview
633
- (`location.pathname + location.search`, current title) whenever the path
634
- changes, since it's already rendered on nearly every real page. Excluded
635
- from the effect's dependencies on purpose: a title-only change (e.g. a
636
- language switch) does not fire a new pageview, only an actual navigation
637
- does.
638
-
639
- ## [1.7.0] - 2026-07-30
640
-
641
- ### Added
642
-
643
- - **New `JsonLd` component** - renders a schema.org `<script type="application/ld+json">` block from a plain object, with `<` escaped to `\u003c` so a string value containing "</script>" can't break out of the tag. No hoisting needed (unlike Meta's title/meta/link) - Google explicitly supports structured data anywhere in the document, so this renders in place. First consumers: `@autobusal/providers`' `Preload.tsx` (sitewide Organization + WebSite) and `route-view`/magus's `BlogView.tsx` (BusTrip/BreadcrumbList, Article).
644
-
645
- ## [1.6.0] - 2026-07-30
646
-
647
- ### Added
648
-
649
- - **`Meta` now emits a canonical `<link>` and `og:url` on every page by
650
- default**, even when the caller passes no `url` prop - previously `url`
651
- was never passed at any of this component's ~136 call sites across both
652
- apps, so no page anywhere had a canonical tag. Defaults to the current
653
- path (`location.pathname`, via `react-router-dom`'s `useLocation`) with
654
- the origin prepended - query strings are naturally excluded, so a search
655
- results page with `?sfrom=X&sto=Y` or a paginated `?page=N` correctly
656
- canonicalizes to its bare path instead of creating unbounded near-duplicate
657
- URLs. `url` can still be passed explicitly to override.
658
-
659
- ## [1.5.1] - 2026-07-30
660
-
661
- ### Fixed
662
-
663
- - **`Meta.tsx` and `@autobusal/providers`'s `Preload.tsx` both render a
664
- `<title>`, and React 19's native hoisting (added in 1.5.0, see above) does
665
- NOT dedupe `<title>` between separate component instances.** Both ended up
666
- as real, simultaneous DOM nodes - technically invalid HTML (a document must
667
- not have more than one title element), even though `document.title` itself
668
- still resolved correctly by spec ("first title in tree order" happened to
669
- always be this one). `Meta` now takes exclusive ownership on mount: it
670
- removes any other `<title>` element in `<head>` the instant its own real
671
- page title exists, so exactly one survives.
672
-
673
- ## [1.5.0] - 2026-07-30
674
-
675
- ### Fixed
676
-
677
- - **`Meta.tsx` migrated off `react-helmet` to React 19's native document
678
- metadata support.** react-helmet 6.1.0 (unmaintained since ~2021) was
679
- discovered to be completely non-functional under React 19: zero of its DOM
680
- writes were ever committing on any route, on any fresh page load, in either
681
- consuming app - confirmed via the absence of `[data-react-helmet]` in the
682
- DOM and by bisecting back through the app's dependency history to before
683
- any of today's changes. Every page's `<title>`, `description`, `robots`,
684
- `og:*`/`twitter:*` tags were silently frozen at whatever the static
685
- `index.html` shipped, for every real user and JS-executing crawler landing
686
- directly on a URL. `<title>`/`<meta>`/`<link>` are now rendered as plain
687
- elements - React 19 auto-hoists and deduplicates them into `<head>` with no
688
- library needed, and the same "deepest/last-rendered wins" semantics Helmet
689
- was supposed to provide (a page's own `<Meta>` overriding
690
- `@autobusal/providers`'s app-shell fallback title) now actually work.
691
-
692
- ## [1.4.7] - 2026-07-30
693
-
694
- ### Fixed
695
-
696
- - **Blog thumbnails missing `alt` text (`BlogItem/Image.tsx`).** Neither the
697
- preview/short grid thumbnail nor the full article image had `alt` - added a
698
- required `title` prop (threaded through from `BlogItem.tsx`'s
699
- `article.title`) used as the `alt` text on both.
700
-
701
- ## [1.4.6] - 2026-07-29
702
-
703
- ### Fixed
704
-
705
- - `Autocomplete/Autocomplete.tsx`: the "From"/"To" route-search inputs kept showing a station's display name in the language that was active when it was selected, since the displayed text (`q`) was plain component state that never resynced when `data` was rebuilt with fresh, relocalized names after a language switch (e.g. "(Alle Stationen)" stayed baked in after switching German -> English). Now re-derives the display name from the selected `value` whenever `data` changes.
706
-
707
- ## [1.4.5] - 2026-07-28
708
-
709
- ### Fixed
710
-
711
- - `File/File.tsx`: a `required` file-input field blocked saving ANY record that already had a file (image, document, etc.) unless the user re-uploaded it, since a file input can never carry over its previous value - the `required` rule now skips when a `value` (existing file URL) is already present. Found via a live sanity pass: this froze several existing records (e.g. a bus with a real image) out of being editable at all.
712
-
713
- ## [1.4.4] - 2026-07-24
714
-
715
- ### Fixed
716
-
717
- - `BlogItem.tsx` and `Policy.tsx`: article/policy content is now run through `@autobusal/utilities`'s `sanitizeHtml` before being rendered via `dangerouslySetInnerHTML`, closing an XSS gap where an already-compromised admin account could persist JS in blog articles or policy pages.
718
-
719
- ## [1.4.3] - 2026-07-19
720
-
721
- ### Changed
722
-
723
- - **`Meta` (`Meta.tsx`) is now brand-configurable, eliminating the per-brand SEO fork.**
724
- Merged magus's rich SEO `Meta` (robots, canonical, Open Graph and Twitter card tags, plus
725
- optional `image`/`url`/`type`/`noIndex` props) into the shared component, and replaced the
726
- hard-coded "BusMagus" strings with a `VITE_APP_NAME` build-time env: the `<title>` suffix,
727
- `og:title`/`twitter:title` and `og:site_name` derive from that brand name, and when it is
728
- unset the title renders bare (no suffix) with `og:site_name` omitted — so a brand that has
729
- not configured a name is unaffected. This lets every whitelabel consume the published
730
- `Meta` and pass its own brand via env, instead of magus vite-aliasing `@autobusal/common`
731
- to a local `src/modules/Common` copy just to override this one file. `children` is now
732
- optional; the prop set is a superset of the previous one, so all existing `<Meta title=…>`
733
- call sites are unaffected.
734
-
735
- ## [1.4.2] - 2026-07-19
736
-
737
- ### Added
738
-
739
- - **Cross-field validation + masked pre-fill for `Password` (`Password/Password.tsx`).** Added
740
- an optional `getValues` prop (react-hook-form's `UseFormGetValues`) that is threaded into
741
- `Validate(...)`, enabling cross-field rules like `matches:password` (e.g. a
742
- password-confirmation field). Added an optional `defaultValue` prop applied to the input so
743
- an admin edit form can render a saved secret as masked dots instead of blank, so editing a
744
- different field no longer wipes the stored password on save.
745
- - **Keyboard accessibility for the calendar day picker (`Calendar/Days.tsx`,
746
- `Calendar/styles.ts`).** Day cells now expose `role="button"`, `tabIndex` (`-1` when
747
- disabled), `aria-disabled`, `aria-pressed`, and an `onKeyDown` handler that activates on
748
- Enter/Space, plus a `:focus-visible` outline. Days are now selectable via keyboard, not
749
- only mouse.
750
- - **Debounced type-to-filter search (`Table/Top/Search.tsx`).** The table search input now
751
- submits automatically ~500ms after the user stops typing (via a `useEffect`/`setTimeout`
752
- with an initial-render guard using `useRef`), instead of requiring an Enter press that
753
- nothing on screen advertised. Explicit Enter submit is preserved.
754
-
755
- ### Changed
756
-
757
- - **`Password` pre-fill wired through the dynamic viewer (`Viewer/Data.tsx`).** The `password`
758
- field case now passes `defaultValue={ value.string }` to `Password`, so saved password
759
- values hydrate as masked input in generated forms.
760
- - **Confirm-password persistence of language selection (`ChangeLanguage/ChangeLanguage.tsx`).**
761
- Language changes are now persisted to `localStorage['language']` so the chosen language
762
- survives reloads rather than resetting to the default.
763
- - **Explicit `common` namespace on i18n lookups (`Gender.tsx`, `Seats/ChooseSeat.tsx`).**
764
- Translation calls now pass `{ ns: 'common' }` (gender male/female labels; seat
765
- chosen/choose labels) so the correct namespace resolves and the raw key is no longer shown
766
- when the component is used outside the default namespace context.
767
- - **Styled Calendar browse dropdown (`Calendar/Browse/styles.ts`).** The select now has a
768
- visible border, border-radius, padding, pointer cursor, and a custom chevron background,
769
- replacing `border: none` so it reads as an interactive control.
770
-
771
- ### Fixed
772
-
773
- - **Missing image alt text (`Avatar.tsx`, `CompanyItem/CompanyItem.tsx`, `RouteItem/RouteItem.tsx`).**
774
- Operator/company logo `<img>` tags now include `alt={ ...company }`, giving screen readers a
775
- meaningful label and a fallback when the logo fails to load.
776
-
777
- Authored by Ferjolt Ozuni. Consolidated from the magus and alvavel whitelabel patch sets into canonical @autobusal source (eliminates per-repo patch-package divergence).