# Changelog

## [0.36.0] - 2026-10-25

### Added

- **A layout can sit in `u-list-page`'s `slot="view"` around the table.** When the slotted element does not take the
  source itself, the skeleton binds the first view inside it, so `<u-master-detail-layout slot="view">` with the table
  and a `slot="detail"` preview works: the preview starts level with the table, below the filters and toolbar, rather
  than at the top of the screen. Selection still reaches the skeleton's selected state. The warning below now fires
  only when neither the slotted element nor anything inside it can take the source.

### Fixed

- **`u-list-page` says when a child is never rendered.** The page draws only its named slots, so a child without
  `slot` — or with a name the page does not have (`slot="filter"`) — was silently dropped: a drawer placed inside the
  page never showed when opened. The page now logs one `[@iyulab/enterprise]` warning per such child, naming it and the
  slots it has.
- **`u-list-page` says when the source cannot reach a slotted element.** A wrapper in `slot="view"` (a layout around
  the table) or `slot="pager"` has no `data` / `page` to write, so the pager reported a count while the table stayed
  empty, with no error. The skeleton now logs one `[@iyulab/enterprise]` warning naming the element; a custom element
  slotted before it is defined is judged once it is defined. The list guide shows the list-with-preview layout.

## [0.35.2] - 2026-10-24

### Fixed

- **A card `u-list-page` keeps one frame around a flex-table or u-rich-table view.** Both tables now draw their
  outline inside their shadow root (so a page-wide CSS reset cannot strip it), where the card's rule that removed a
  view's own border no longer reached — the table's outline came back inside the card's. The card now also sets the
  tables' outline properties (`--ft-outline`, `--dc-table-outline`, `--dc-table-radius`) on its view.

## [0.35.1] - 2026-10-17

### Fixed

- **`u-list-page`'s `filters` region lines up items that are not controls.** A toggle (a checkbox, «include archived») or a
  short text sat on the region's bottom edge, so its middle landed below the middle of the buttons next to it (about
  6–9px, by the density). Fields (an input, a select, a date picker) still share one bottom edge with the buttons; buttons,
  toggles and text now share one text baseline, so a toggle stands on the button's text line at any density — no height
  to copy. An app that padded its toggle to the button's height can drop that padding.

## [0.35.0] - 2026-10-16

### Added

- `ODataRequestOptions.headers`: headers for one call — a purpose mark («this read is an export»), a tracing id, an
  `Idempotency-Key`. Added to every page of a read that follows `@odata.nextLink`; a service default
  (`Content-Type`) gives way to it, and `ifMatch` decides `If-Match`. `fetchRaw` takes it too.

### Changed

- Console diagnostics begin with `[@iyulab/enterprise]`, so a message in a console shared by several packages says which one wrote it (several also say what to do next).

## [0.34.3] - 2026-10-15

### Fixed

- **`@iyulab/enterprise/react` and `@iyulab/enterprise/list-page` can be used in one program.** The React entry's
  declarations carried their own copy of the `UListPage` class for the `'u-list-page'` tag map merge, a second type for
  the same tag, so type-checking both entries together failed with `TS2717`. The merge now refers to the list-page
  entry's class. Types only — runtime unchanged.

## [0.34.2] - 2026-10-15

### Fixed

- **The main entry and `@iyulab/enterprise/icons` type declarations compile on their own.** They carried the
  `'u-list-page'` tag map merge without the `UListPage` class (it lives in `@iyulab/enterprise/list-page`), so a
  project checking library types (`skipLibCheck: false`) failed with `TS2304: Cannot find name 'UListPage'` just for
  importing the package — since 0.28.0. The merge now refers to the list-page entry. Types only — runtime unchanged.

## [0.34.1] - 2026-10-14

### Fixed

- **`u-list-page`: an initial selection keeps `:state(selected)` on.** A view that reported its selection before the
  skeleton's first render (a table created with a selection in the same render pass) was ignored, because the view
  region was judged by the slot assignment, which did not exist yet. The region is now judged by the child's
  `slot="view"`.

## [0.34.0] - 2026-10-14

### Added

- **`u-list-page`: `:state(selected)` while the visible view has a selection.** The skeleton reads the view's
  `selection-change` (`selectedIds`) and sets the custom state while it is non-empty, so a theme can draw the card
  toolbar as a bulk-action bar — `u-list-page:state(selected)::part(toolbar) { … }` — without each screen mapping its
  selection count onto a toolbar class. A hidden view's selection does not count, a view that leaves takes its
  selection with it, and the same event from outside the view region is ignored. The skeleton still does not bind
  the selection. The default look is unchanged.

## [0.33.0] - 2026-10-14

### Added

- **`u-list-page` `slot="applied"` — the conditions in force.** A wrapping row of chips (`u-chip removable`) under the
  filters and above the toolbar, more tightly spaced than the filters; it folds and leaves no gap when the last chip
  goes. Part `applied`.
- **`u-list-page` `appearance="card"` — toolbar, view and pager on one surface.** One border, lines between the regions
  instead of gaps, the pager padded (the toolbar runs edge to edge — its content brings its own padding), and a view's own frame (`flex-table`, `u-rich-table`) removed
  inside it. The header, filters and applied conditions stay outside. Part `surface`. The default (`plain`) is
  unchanged.

## [0.32.2] - 2026-10-13

### Fixed

- **`ApiError.code` also reads a top-level `Code`.** The message was read from `error.message`, `message` and `Message`,
  but the code only from `error.code` and `code`, so a `{ "Code": "…", "Message": "…" }` body kept its sentence and lost
  the code that tells same-status failures apart. The order is `error.code` → `code` → `Code`; an empty string is still
  no code. `formatError` receives the same value, and the auth client reads it the same way.

## [0.32.1] - 2026-10-13

### Fixed

- **A searchable `u-select` in the `filters` region no longer drives the list search.** `u-select searchable` fires
  `search { query }` on every keystroke while it narrows its own options; `u-list-page` took that as the list query,
  so typing to find an option re-queried the list with those letters. The skeleton now ignores `search` from a select
  (itself or wrapped, e.g. in a `u-field`); a search box's `search` still becomes `setSearch(query)`.

## [0.32.0] - 2026-10-12

### Added

- **A `signal` for the auth client and `fetchRaw`** — `fetchMe(options?)`, `login(credentials, options?)`,
  `logout(options?)` take `AuthRequestOptions` (`{ signal?: AbortSignal }`), and `fetchRaw(url, opts?)` takes
  `{ signal }`. A session check or reachability probe can now put a time budget on a server that is slow to answer and
  drop the request it no longer waits for. An aborted auth call still does not throw: `fetchMe` gives
  `{ status: 'unknown' }`, `login`/`logout` give `{ ok: false }`, with `error.status` `0`, `error.cause` the
  `signal.reason`, and the new `canceled` message. `fetchRaw` rejects with `signal.reason`, like the other service
  methods.

### Changed

- 🔴**The server's rejection sentence is shown again by default** (reverts the 0.31.0 rule). In OData JSON the
  `code` is a language-independent identifier and the `message` is the human-readable sentence, so a category code with
  a specific sentence — `{ "code": "Forbidden", "message": "'orders.read' permission is required." }` — is shown as is.
  A new `serverMessages` option on `createODataService` and `createAuthClient` brings back the 0.31.0 behavior:
  `'domain-codes'` treats a sentence whose `code` only names the HTTP status as framework boilerplate and shows the
  localized message instead. **Migration**: if your server sends its framework's default English envelope for
  failures that used to have no body, set `serverMessages: 'domain-codes'`.
- The data service's and auth client's built-in messages (toasts, `messages.http`, fallbacks) now come from the
  package's `messages` locale namespace, with English and Korean built in, and are read when used — `Locale.set()`
  switches them like the helpers' labels. Keys you pass in `messages` still win. Under a Korean locale an app that
  passed no `messages` now sees Korean text where it saw English.

## [0.31.0] - 2026-10-12

### Added

- **`AuthClientConfig.loginUser`** — `'body'` (default) or `'me'`: where the session user comes from after a
  successful login. With `'me'` the client asks `meUrl` again, so `LoginResult.user` is the session user and
  `getPermissions` reads it — for servers whose login response is an envelope (`{ Success, User, Token }`) rather
  than the session user. A login whose `me` is not authenticated fails. A successful `LoginResult` also carries the
  login response as `body` (`unknown`).

### Changed

- A failure whose error `code` only names its HTTP status (`Forbidden` for 403, `NotFound` for 404,
  `MethodNotAllowed`, `Conflict`, `InternalServerError` …) shows the localized `messages.http[status]` (or the auth
  client's fallback) instead of the server's sentence. Servers that began sending an OData error object for
  failures that had no body — and for unhandled exceptions — put transport boilerplate where users saw a localized
  message. A domain code's message (`KeyNotFound`) is still shown, and `formatError` still receives it as `rawMessage`.

## [0.30.0] - 2026-10-10

### Added

- `applyFieldErrors(root, error, { message? })` puts a rejection's field details on the form: each
  `ApiError.details` entry with a `target` sets its message on the control whose `name` is the target — native form
  elements and `@iyulab/components` form controls, which show the error. Editing that field clears the message, and
  calling the function again clears the earlier ones first. It returns `{ applied, formLevel }`: the details placed on
  controls (for an error summary that links to them) and the ones that had no target or no control of that name.
  `message` maps a detail to the text shown — for example to localize `Unconvertible` by `detail.code`. Call it after
  unlocking a form that was disabled while saving; a disabled control is barred from validation.
- `clearFieldErrors(root)` removes the messages `applyFieldErrors` put inside `root`.

## [0.29.0] - 2026-10-09

### Removed

- **Breaking** — `CurrencyHelper`, deprecated since 0.8.2. Migrate:
  - `CurrencyHelper.formatKRW(x)` → `formatCurrency(x, 'KRW', undefined, 'ko-KR')` from `@iyulab/components`; likewise
    `formatUSD` (`'USD'`, `'en-US'`) · `formatEUR` (`'EUR'`, `'de-DE'`) · `formatJPY` (`'JPY'`, `'ja-JP'`) · `formatCNY`
    (`'CNY'`, `'zh-CN'`). Leave the locale out to follow the active `Locale`. The helper capped fractions at 0 for KRW
    and 2 otherwise; `Intl` already uses each currency's own digits (KRW 0, USD 2).
  - `CurrencyHelper.formatCurrency(x, currency, locale)` → `formatCurrency(x, currency, undefined, locale)`.
  - `null`/`undefined` no longer turn into `—` for you: `x == null ? EMPTY_VALUE_DISPLAY : formatCurrency(x, 'KRW')`.
  - `CurrencyHelper.parseCurrency(text)` → `parseNumber(text.replace(/[^\d.,+\-−]/g, ''))` from
    `@iyulab/components` — it returns `null` (not `0`) for text that is not a number.

### Added

- `ApiError.body` — the failed response's body (parsed JSON, else the text, `undefined` when empty), from both the data
  service and the auth client. A rejection that carries the conflict's current state (a 409's new verdict, a 412's current
  representation) can be read without sending the request again. It is the same value `formatError` already received.
- `ApiError` sets the standard `cause` to the transport's exception when the auth client got no response (`status` 0).
- `ApiErrorInit` — the type of `ApiError`'s optional constructor fields (`code`, `details`, `body`, `cause`).

### Changed

- Comments inside the components' `css` styles are no longer shipped. A tagged template's body is a string, so a
  consumer's bundler could not remove them; they were design notes, sent to every browser.
- A non-JSON failure body is no longer dropped: `formatError`'s `body` and the auth client's `extractLoginError` receive
  the text when the response is not JSON (previously `undefined`).

### Documentation

- The list guide spreads `svc.sourceDefaults` (`{ baseUrl, onUnauthorized }`) into the data source, so a list follows
  the service's base URL and session policy — an expired session goes to sign-in from the list as it does from a write.
- Examples import `Locale`/`Theme`/`formatCurrency` from their own module in `@iyulab/components`
  (`dist/utilities/…`) — the components barrel registers every component even when one value is taken from it.

## [0.28.4] - 2026-10-08

### Documentation

- The auth guide shows the boot gate of `@iyulab/modern-app` 0.45 taking `fetchMe()`'s answer as is
  (`me: () => auth.fetchMe()`).

## [0.28.3] - 2026-10-08

### Documentation

- The list skeleton's React example takes its source from `useODataSource(...).source` (`@iyulab/flex-table` 0.64) —
  options stay props, and the views are left without `data`.

## [0.28.2] - 2026-10-08

### Fixed

- **`u-list-page`'s `filters` region lays its elements out in a row.** Two search fields, or a field and a button, sat
  against each other with no space between them. They now share one row with `--list-page-gap` between them, wrap when
  the page is narrow, and align at the bottom so a labelled field and an unlabelled button stand on the same line.
  `toolbar` stays a block, so an action row there (`u-action-bar`) keeps the full width.

## [0.28.1] - 2026-10-08

### Fixed

- **`0.28.0` was tagged but never published to npm** — the release workflow's type check failed in a standalone
  install: the React wrapper test imports `react-dom/client`, whose types (`@types/react-dom`) were not declared and
  had only been present through the workspace. No package code changed; `0.28.1` is the first published release of
  everything listed under `0.28.0`.

## [0.28.0] - 2026-10-08

### Added

- **`<u-list-page>`** (`@iyulab/enterprise/list-page`) — the list screen as slots: `header`, `filters`, `toolbar`,
  `view`, `pager`, `empty`, `error`. Elements in `view` and `pager` are bound to `source` with `bindSource`
  (including ones added later); `view` picks which view shows by its `view-name` and the others get `hidden`; a
  `search { query }` from the header, filters or toolbar becomes `setSearch`; `slot="error"` and `slot="empty"` replace
  the view and the pager while the source has an error or an empty result. A `status` attribute (`loading`, `error`,
  `empty`, `ready`) and `--list-page-gap`. The skeleton draws no table — the element in the slot does.
- **`ListPage`** in `@iyulab/enterprise/react` — a thin `@lit/react` wrapper (`@lit/react` is an optional peer).
- `lit` is a dependency (used by `./list-page` only — the root entry still loads no UI).

### Changed

- The layer charter (`docs/lob-layers.md` §1) — L2 skeletons are framework-neutral custom elements with thin React
  wrappers; the existing React patterns (`FormSection`, `FormRow`) stay as they are.

## [0.27.1] - 2026-10-08

### Documentation

- `bindSource`: the list search box is `u-input type="search"` (`@iyulab/components` 2.17 fires `search { query }`
  when a search is committed). The reference named `u-select searchable`, whose `search` is the search of its own
  option list, not of a list.

## [0.27.0] - 2026-10-07

### Added

- **`bindSource(source, element)`** — ties a data source (`createODataSource` / `createArraySource` of
  `@iyulab/flex-table`, known by its shape — no dependency) to an element that speaks the list view vocabulary:
  `flex-table`, `u-rich-table`, `u-data-view`, `u-pagination`, a search box. It writes `data`, `totalCount`,
  `loading`, `error`, `sortCriteria`, `page` and `pageSize` — only the properties the element has and can set, and
  only when the source value changed — puts an element that has `dataMode` into `'server'`, and turns
  `sort-change { criteria }`, `page-change { page, pageSize }` and `search { query }` into `setSort`, `setPage` /
  `setPageSize` and `setSearch`. It returns the unbind function. A list screen is then the same wiring whichever view
  shows it.
- **`SourceBinding`** — the Lit adapter: binds the elements a host renders, rebinds when they change, unbinds on
  disconnect. Structural host type — no `lit` dependency.

## [0.26.0] - 2026-10-06

### Changed (breaking)

- **`createAuthClient().logout()` returns a `LogoutResult` and no longer reports a failed sign-out as done.** It
  ignored the response and swallowed network errors, then cleared the permission store — so when the server could
  not end the session (a 5xx, or no answer) the screen moved to sign-in while the session cookie stayed valid, and at
  a shared terminal the next person reloading the page was signed in. Now 2xx and 401 (the session is already gone)
  return `{ ok: true }` and clear permissions; any other answer or a network failure returns
  `{ ok: false, message, error }` (the shape of `LoginResult`) and leaves permissions alone. It still never throws.
  Callers that `await auth.logout()` and then navigate should check `ok` first.

### Added

- `messages.logoutFailed` — fallback when a failed sign-out carries no server message (default `Could not sign out.`).

## [0.25.1] - 2026-10-06

### Documentation

- The `FormSection`/`FormRow` examples (README and skill references) used `<u-input>`/`<u-textarea>` in TSX, which a TypeScript React app cannot compile without its own element typings. They now use the `UInput`/`UTextarea` React wrappers from `@iyulab/components/react`, like the Quick Start.

## [0.25.0] - 2026-10-06

### Changed (breaking)

- **`createAuthClient().fetchMe()` returns a `SessionState`**: `{ status: 'authenticated', user }`,
  `{ status: 'anonymous' }` (the server answered 401), or `{ status: 'unknown', error }` (any other
  non-2xx, no response, or an unreadable body). It used to return `null` for all of these, so a
  brief 503 or going offline looked exactly like being signed out and sent signed-in users to the
  login screen. `unknown` leaves the permission store untouched. Migrate `if (!user)` to
  `if (session.status === 'anonymous')` and decide what `unknown` shows (keep the screen, retry).
- **`LoginResult` is a discriminated union**: `{ ok: true, user }` or `{ ok: false, message, error }`.
  `error` is an `ApiError` with `status` (`0` when there was no response), and the server's `code`
  and `details` read from the same error envelope as the data service, so a 429 or a 403 with a
  rejection code can be told apart. `message` is unchanged.
- The default `extractLoginError` also reads the OData envelope's `error.message` (before `message`
  and `Message`). A server message longer than 200 characters falls back to `loginFailed`, as the
  data service already does.

### Added

- **`PermissionStore.isKnown()`** and **`permissionsKnown()`**: whether permissions have been
  answered yet. A store created without `initial` starts *not known*; `set` and `clear` make it
  known. Until then an empty set means "not answered", not "no permissions".
- `AuthClientMessages.sessionCheckFailed` and `invalidResponse`.

## [0.24.1] - 2026-10-06

### Documentation

- **Quick Start** — a first React page (a form section with `FormSection`/`FormRow`, the
  `@iyulab/components` React wrappers and `DateHelper`) with the full install line, including the
  `@lit/react` peer the wrappers need. It builds, type-checks and renders as written.

## [0.24.0] - 2026-10-05

### Added

- **`ApiError.code`** — the server's rejection code from the OData error envelope (`error.code`, or a
  top-level `code` when there is no envelope); `undefined` when absent or empty. A status alone could
  not tell apart failures the server distinguishes by code (for example a 403 that means "change your
  password" from any other 403). `formatError` receives the same `code`, and it stays on the error
  when `formatError` replaces the message. The OData coverage table listed `error.code` as
  implemented; it is now carried.

### Changed (breaking)

- **`new ApiError(message, status, init?)`** — the third argument is `{ code?, details? }` instead of
  the details array. Only code that constructs `ApiError` itself (test doubles, adapters) changes:
  `new ApiError(msg, 400, details)` becomes `new ApiError(msg, 400, { details })`.

## [0.23.0] - 2026-10-05

### Removed (breaking)

- **`styles/preset.css` moved to `@iyulab/house-style`.** The house values (type scale, radius,
  elevation) now live in the house theme package together with the neutral ramp, status colours,
  component recipes and layout patterns, in the `iyu.house` cascade layer.
  **Migration:** replace `import '@iyulab/enterprise/styles/preset.css'` with
  `import '@iyulab/house-style'` (needs `@iyulab/components` 2.0). The `./styles/preset.css` export
  and the build's style-copy step are gone.

### Added

- `palette` in the `house` icon set (`@iyulab/enterprise/icons`).

## [0.22.0] - 2026-10-04

### Fixed

- **`sourceDefaults.onUnauthorized` no longer reports a `403` as a session expiry.** It ignored the
  response it was given and always called `config.onUnauthorized(401)`, and a table data source
  that also calls the hook on `403` (`useODataSource` before `@iyulab/flex-table` 0.43.0) sent a
  signed-in user without permission to the sign-in page — and back to the same list after signing
  in. It now takes the response and forwards only a `401`, as every other path of the service
  does. The type changes from `() => void` to `(response: Response) => void`.

## [0.21.0] - 2026-10-04

### Added

- **Cancelling a call: `ODataRequestOptions.signal`** — a standard `AbortSignal` on every read and
  write. A cancelled call rejects with `signal.reason` itself (an `AbortError`), not `ApiError`, and
  neither `notify.error` nor `onMutated` runs. A screen that drops the previous request on the next
  click no longer has to fall back to raw `fetch`.
- **Optimistic concurrency: `ODataRequestOptions.ifMatch` and `etagOf(entity)`** — send the entity's
  `@odata.etag` back as `If-Match`; a concurrent change makes the server answer 412, thrown as
  `ApiError(…, 412)` with the new default message `messages.http[412]`. `odataGetById` fills
  `@odata.etag` from the `ETag` header when the body has none.

### Changed

- Requires `@iyulab/http-client` 0.14 (per-request headers and `AbortSignal`).

### Documentation

- LOB layer charter §3-1: a pattern is promoted by whether it belongs in this layer, whether its
  skeleton has converged, and whether a design meets the override contract — no longer by counting
  independent consumers. A single request is read as a sample of every consumer that will hit the same
  root cause; waiting is for an unsettled design, not for more requests.

## [0.20.5] - 2026-09-30

### Changed

- `@iyulab/http-client` `^0.13.0` (was `^0.12.0`). That release removes the deprecated
  `onRequest`/`onResponse`/`onError` options; this package never used them and does not expose its
  client, so nothing changes for you.
- The optional `react` peer is `^18.0.0 || ^19.0.0` (was `>=18`) — only the majors that are tested.

## [0.20.4] - 2026-09-30

### Changed

- Depends on `@iyulab/http-client` `^0.12.0` (was `^0.11.0`). Reading a response whose connection
  closes mid-body now fails with `IncompleteResponseError`, which extends `TypeError`, so existing
  error handling keeps working.

## [0.20.3] - 2026-09-30

### Changed

- Depends on `@iyulab/http-client` `^0.11.0` (was `^0.10.3`), so an app that installs this package gets
  the http-client that applies `timeout` to the response body as well as the headers and adds
  `idleTimeout` for streams. The OData and API calls here pass no timeout of their own, so nothing
  changes unless your app configures one.

## [0.20.2] - 2026-09-30

### Fixed

- **`DateHelper.formatDate` and `formatDateTime` write the local date and time.** They cut the UTC
  string, so in time zones ahead of UTC the early hours of a day came out as the previous day and the
  time was off by the offset (in Korea, 03:05 on the 29th was written as `2026-09-28 18:05`). A
  date-only string such as `'2026-09-29'` is now read as that day in the local time zone everywhere in
  `DateHelper`; it was read as UTC midnight, which is the previous day west of UTC.
- `DateHelper.formatLocalDate` defaults to the app locale (`Locale.get()` from `@iyulab/components`)
  instead of `'ko-KR'`.
- **`ApiConfig` ignores a trailing `/` on `baseUrl` and slashes around the prefixes.** `baseUrl: '/'`
  produced `//$data/Orders`, which a browser reads as a URL on another host.

### Documentation

- README corrections: which writes call `onMutated` (there is no `odataPut`), `fetchRaw` takes no
  per-call options, `fetchMe()` returns `null` for any non-2xx response, `CurrencyHelper` is deprecated,
  the `@iyulab/components` peer and the `/icons` and `/styles/preset.css` entry points.

- The package now ships an agent skill (`skills/iyulab-enterprise/`) — the OData service, auth and
  permissions, form layout and helpers in a form coding agents load directly.

## [0.20.1] - 2026-09-28

### Fixed

- **A server-issued `@odata.nextLink` is followed exactly as written.** `odataGet` and
  `odataGetNextPage` sent the link's query re-encoded (`$skiptoken` → `%24skiptoken`). Requires
  `@iyulab/http-client` 0.10.3, now the minimum.

### Documentation

- The README gains an **OData v4 conformance** table: what the service implements, partly implements
  or leaves out, each backed by a test that replays the specification's own example responses.

## [0.20.0] - 2026-09-28

### Changed

- **`FormSection` and `FormRow` moved to `@iyulab/enterprise/react`, and `react` is now an
  optional peer dependency.** The root entry exported them, so `react` had to be a required peer
  and npm installed React into every app that used this package — including Lit apps that only
  load the preset or the data services. The root entry no longer imports React. **Migration:**
  `import { FormSection, FormRow } from '@iyulab/enterprise/react'`.

## [0.19.0] - 2026-09-27

### Added

- **`onMutated` on `createODataService`** — called once after every successful write (`odata*` and
  `api*` POST/PUT/PATCH/DELETE, including the `*Quiet` variants) with
  `{ method, source, target, id? }`. Apps that keep the same data in several places (a list with a
  detail overlay on top, a dashboard) can invalidate from one hook instead of wrapping every write
  method — a wrapper silently misses a write method added later. Not called for failed writes or
  reads. If the callback throws, the write still resolves; the error goes to `reportError`.

## [0.18.0] - 2026-09-24

### Fixed

- 🔴 **`odataGet` no longer drops `@odata.nextLink`.** It returned only the first response's `value`,
  so when a server pages a collection (server-driven paging, e.g. a default page size), a list read
  without `$top` ended at the first page with nothing telling the caller rows were missing.
  `odataGet` now follows `nextLink` until the collection is complete and returns every row; a
  response without `nextLink` behaves exactly as before. A `nextLink` outside the service origin,
  or one that returns to a page already read, throws instead of silently stopping — the requests
  carry the session, and a quietly shortened list is the failure this fixes.

### Added

- **`odataGetPage(entity, params?, opts?)` and `odataGetNextPage(nextLink, opts?)`** for screens that
  read a page at a time. A page is `{ value, nextLink?, count? }` (`count` when `$count=true`); pass
  `nextLink` as given — it carries the server's continuation state.
- **`odataGet(entity, params, { maxRows })` — an upper bound on the rows it collects.** Following
  every page means a collection larger than a screen expects is read in full. With `maxRows`, the
  read stops requesting further pages once it has more rows than that and throws a `RangeError`
  instead of returning a shortened list (a collection of exactly `maxRows` rows passes). Without
  it there is no bound. The options type is `ODataGetOptions`, which extends
  `ODataRequestOptions`.

## [0.17.0] - 2026-09-23

### Added

- **`ApiError.notified` and `wasNotified(err)` — whether the service already told the user about a
  failure.** A failed write (`odataPost/Patch/Delete`, `apiPost/Put/Patch/Delete`) is reported
  through `notify.error` and then rethrown, so the caller's flow stops. When that error reaches a
  global `unhandledrejection` handler or an error boundary, the boundary could not tell it from a
  failed read, which the service does not report: toasting again showed the message twice, not
  toasting left read failures silent. The error now carries the answer. `notified` is `true` only
  when the service actually called `notify.error` — reads, `*Quiet` writes, 401s and services
  without `notify.error` give `false` — and it is read-only: only the service can set it.
  `wasNotified(err)` answers the same for failures that are not `ApiError`, such as a network
  error on a write.

## [0.16.0] - 2026-09-22

### Documentation

- 🔴 **`styles/preset.css` now says why its documented usage used to do nothing.** The header's
  third clause said layer order is decided by load order and is therefore already under the
  consumer's control. It was not: the defaults layer is injected at runtime by
  `@iyulab/components`' `Theme.init()`, so until that package's **1.44.0** it landed *after* a
  statically imported sheet and won. The one-line `import` this file documents had **no effect at
  all**, silently — and because the two sheets agree on `body`/`label`/`caption`/`overline` and
  differ only on the upper type steps and the radii, it still looked applied. The clause now
  states that, names the version where the documented usage starts working, and says what to do
  on an older one.
- **`docs/lob-layers.md` carried the same third clause, and the same false statement with it.**
  A rule that lives in two published documents goes stale in two places; that one now names the
  premise this package cannot hold on its own -- load order is decided by whoever injects the
  defaults layer -- and says which version makes the documented arrangement true.
- **The header also called the `@iyulab/components` peer optional. It is not** — this package's
  runtime code imports `Locale`, `format` and `icons` from it, so it is required whether or not
  you load the preset. The prose was the wrong half of that mismatch and has been corrected.

### Changed

- 🔴 **The `@iyulab/components` peer range is now `>=1.44.0`** (was `>=1.27.0`). Every version
  below that places the defaults layer last, which is exactly the arrangement in which this
  package's documented preset usage is silently void. A range that admits those versions lets a
  consumer install a combination where the preset does nothing and nothing says so — declaring
  the floor is what turns that from a silent failure into an install-time answer.

⚠ **This is the only reason for the minor.** No API changed.

## [0.15.0] - 2026-09-22

### Fixed

- **`fetchRaw` now matches its own declaration.** It was documented as returning the raw
  response, but the implementation ran every response through `throwIfError` and threw on any
  non-2xx — and it invoked the session hook while doing so. A connection probe that only asks
  "did a response arrive at all" therefore counted a `401` as *unreachable*, when the server was
  in fact alive and refusing, so every signed-out user saw a permanent "cannot reach the server".
  The declaration was the right one, so the implementation changed to match it: `fetchRaw` returns
  the response as-is and calls no session hook.
  ⚠ **If you relied on the throw**, check `res.ok` yourself, or call `apiGet` when you want the
  error policy applied. Nothing else in the service changed its throwing behavior.

### Added

- **Per-call control over the `401` hook.** `onUnauthorized` was all-or-nothing for the whole
  service, but a sign-in call is the one place where `401` means "wrong credentials" rather than
  "your session expired" — so a failed sign-in fired the global hook and threw a message about an
  expired session, leaving the screen to invent a reason for a failure it had been told the wrong
  cause of. Every request method now takes an optional trailing `ODataRequestOptions`; passing
  `{ onUnauthorized: false }` keeps that call out of the global hook and throws with the message
  the server actually sent. Omitting it changes nothing.
- The option is accepted by **all** request methods, not only the ones with a use for it today.
  The failure being fixed is precisely that a single method had no way to express this, and drawing
  the line again one method further along would reproduce it.

### Notes

- **No `authenticate()` primitive was added**, though one was proposed. That name would have to
  assume an endpoint path, a request body shape and a token convention — none of which this
  library owns — so it would encode one caller's server into a general client.

## [0.14.0] - 2026-09-20

### Fixed

- **A failed `api*` write told the user nothing.** `notify.error` was wired into the OData writes
  only, so a custom REST write that came back 4xx threw an `ApiError` and left the screen exactly as
  it was — indistinguishable from nothing having happened, even when the server had returned a
  precise reason. The four `api*` writes now notify on failure and rethrow, on the same terms as
  their OData siblings (401 excluded, since `onUnauthorized` already covers it). Reads are unchanged
  and still silent on both sides.

### Added

- **`apiPostQuiet`, `apiPutQuiet`, `apiPatchQuiet`, `apiDeleteQuiet`.** The opt-out for the above,
  matching the existing `odata*Quiet` naming: use them when one user action issues several requests,
  or when the calling code already reports failures itself and would otherwise show two messages.

### Documentation

- **The README said which hooks exist but not where they apply**, which read as "wire `notify` and
  failures become visible". It now carries a per-method table and states the axis: writes notify,
  reads do not, `*Quiet` opts out, 401 never notifies, and `api*` has no success message because the
  library cannot invent wording for an arbitrary endpoint.

## 0.13.0

### Changed

- **`FormRow`'s grid tracks are now `minmax(0, 1fr)` rather than `1fr`, so columns declared
  equal actually render equal.** A grid item's default `min-width: auto` refuses to shrink below
  its content's min-content width, so one long value in a row was enough to blow out its own
  column and squeeze the rest. Measured in a 400px container with one long unbreakable string:
  a two-column row laid out at **425px / 8px** and a three-column row at **425px / 8px / 8px**,
  and the row overflowed its container. With `minmax(0, 1fr)` the same cases lay out at
  **196 / 196** and **128 / 128 / 128**. Nothing about the API changes, and rows whose content
  already fit are unaffected.
  - Long unbreakable content still overflows its own column — that is a separate axis, handled
    on the cell with `overflow-wrap` (this package's peer elements already set it).

- **`@iyulab/components` is no longer an optional peer dependency.** The main entry imports it
  unconditionally, so declaring it optional meant a consumer installing this package alone got
  nothing installed and no warning — the failure surfaced only as a module-not-found at
  runtime. Consumers already installing it are unaffected.

## 0.12.0

### Added

- **`ApiError.details`** — OData v4 error envelopes carry per-field validation
  errors in `error.details`, and `ODataService` already parsed that envelope to
  build `ApiError.message`, but discarded the rest of the same parse. `ApiError`
  now also carries `details` (`ApiErrorDetail[] | undefined`), so a caller can
  bind server-side validation failures to the fields that produced them instead
  of showing one flattened string. Backward compatible: callers that never read
  `.details` are unaffected.
  - Per the OData JSON Format v4.0 spec each detail entry MUST have `code` and
    `message` and MAY have `target`, so `target` is optional on `ApiErrorDetail`
    and entries that lack a string `code`/`message` are dropped; when nothing
    usable remains `details` is `undefined` rather than an empty array.
- **`formatError(info)` now receives `details`** — the callback that shapes the
  user-facing message previously got only `rawMessage` and the raw `body`, so an
  app wanting to summarize per-field failures had to re-dig and re-validate the
  same `error.details` the service had just parsed. `info` now carries the
  validated array alongside the derived `rawMessage` it already had. Purely
  additive to a callback input: existing implementations ignore the new field.

## 0.11.1

### Fixed

- **`createAuthClient`'s README section documented only 4 of `AuthClientConfig`'s
  7 fields** — `baseUrl`, `credentials`, and `extractLoginError` had no mention
  anywhere (only `permissionStore` was covered, in prose). Added a config table
  matching the one already used for `createODataService`.

## 0.11.0

### Added

- **`ODataService.odataPatchQuiet`/`odataDeleteQuiet`** — quiet counterparts to
  `odataPatch`/`odataDelete`, mirroring the existing `odataPost`/`odataPostQuiet`
  split. For an action that fires more than one mutation in response to a single
  user click (e.g. editing a row and syncing a denormalized parent field), the
  toasting variants would show a toast per mutation; the quiet variants do the
  request and error handling without one, so the caller can toast once for the
  whole action. Previously PATCH/DELETE had no quiet path, forcing a raw
  `fetch()` that lost 401 handling and `ApiError` extraction.

## 0.10.2

### Changed

- **`CurrencyHelper` now logs a one-time `console.warn`** (per process) the first time any of its
  `format*` methods (`formatCurrency`/`formatKRW`/`formatUSD`/`formatEUR`/`formatJPY`/`formatCNY`)
  is called, pointing callers to `formatCurrency`/`formatNumber`/`formatDate` from
  `@iyulab/components` directly. `parseCurrency` is unaffected (no equivalent exists elsewhere yet).
  No behavior change — this is usage telemetry ahead of a future removal, not a functional change.

## 0.10.1

### Added

- **`ODataService` gained `apiPut<T>(path, body?)`.** The other three custom-REST verbs
  (`apiGet`/`apiPost`/`apiPatch`/`apiDelete`) were already there; `PUT` was the one missing member
  of that set, even though the underlying `@iyulab/http-client` `HttpClient` has always had `.put()`.
  A consumer whose backend models "replace this resource" as `PUT` had no way to reach it through
  this wrapper. Passing a `FormData` body to `apiPost`/`apiPut`/`apiPatch` already worked before this
  change and needed no fix — `HttpClient` never forces JSON serialization on it and lets the browser
  set the multipart boundary itself.

## 0.10.0

### Changed

- **`ProgressHelper.validateProgress`/`ratioToPercent` now return `number | null` instead of
  folding a missing input into `0`.** "No value yet" and "0% progress" are different facts and
  were rendering identically. A call site that already narrows its input to a definite `number`
  keeps getting a definite `number` back (an overload preserves this); a call site that may pass
  `null`/`undefined` now has to handle a `null` result explicitly.
- **`CurrencyHelper.formatCurrency`, `DateHelper.formatDate`/`formatLocalDate`/`formatDateTime`,
  and `UrgencyHelper.formatDaysRemaining` now render a missing value as an em dash (`—`) instead
  of a hyphen (`-`).** The two glyphs looked almost identical but meant different things; the
  empty-value string is now one shared constant (`EMPTY_VALUE_DISPLAY`, exported from
  `@iyulab/enterprise`) instead of eight independent hardcoded literals across the four helpers.

## 0.9.1

### Fixed

- **`CurrencyHelper`, `messages`, and `icons` now deep-import `@iyulab/components`' utility
  modules instead of the full package barrel.** Importing the barrel alongside
  `@iyulab/components/react` in the same TypeScript program produced duplicate custom-element
  registration and nominal-type conflicts for consumers; deep-importing avoids pulling in the
  registration side effect these helpers never needed.

## 0.9.0

### Added

- **New opt-in subpath `@iyulab/enterprise/icons`.** Bundles 10 hand-drawn nav-style SVG
  icons and registers them under the icon-library name `'house'` via `@iyulab/components`'
  `IconRegistry`. Importing the main `@iyulab/enterprise` entry does not pull this in —
  only importing `@iyulab/enterprise/icons` registers the library, matching how
  `./styles/preset.css` is already opt-in.

### Changed

- **`sideEffects` narrowed from `false` to an explicit array.** Protects the new
  `icons.ts` module (and, since this package builds as a single bundle with no
  per-file output, the whole `index.ts`/`index.js` — which also covers a pre-existing
  side effect in `helpers/messages.ts`) from being tree-shaken away by consumers'
  production bundlers.

## 0.8.2

### Changed

- **`CurrencyHelper` now delegates to `@iyulab/components`' `formatCurrency`** instead of
  duplicating `Intl.NumberFormat` logic. Output is unchanged for all existing methods
  (`formatCurrency`/`formatKRW`/`formatUSD`/`formatEUR`/`formatJPY`/`formatCNY`/
  `parseCurrency`) — this is a behavior-preserving internal change. The class is now marked
  `@deprecated`; new code should call `formatCurrency`/`formatNumber`/`formatDate` from
  `@iyulab/components` directly.
- **`@iyulab/components` peer floor raised to `>=1.27.0`** (the delegation above needs
  `formatCurrency`, added in that release).

## 0.8.1

### Fixed

- **`@types/react` was missing from the manifest**, so a standalone checkout (outside the
  monorepo workspace, where hoisting used to paper over it) failed to typecheck against this
  package's React-facing exports. Declared explicitly.

## 0.8.0

### Changed

- ⚠**헬퍼 라벨이 «영어 기본 + 로케일 레지스트리»로 이주했다** — `ProgressHelper` 6건 ·
  `UrgencyHelper` 8건이 한국어 리터럴이었다.

  ```ts
  import { Locale } from '@iyulab/components';
  import { messages } from '@iyulab/enterprise';

  Locale.set('ko');                                   // 검증 메시지와 함께 전환된다
  messages.register('ja', { progressDone: '完了' });   // 언어를 더하거나 문구를 덮는다
  ```

  ⚠**한국어 환경은 종전과 같은 문구를 본다** — 내장 로케일에 `ko` 가 들어 있고, 활성 로케일은
  브라우저 언어에서 감지된다(`ko-KR` → `ko`). 그 밖의 환경은 이제 **영어**를 본다.

  ⚠**호출자가 준 `labels` 인자는 여전히 최우선**이다(`getUrgencyText`) — 종전 계약 유지.

  ★자체 레지스트리를 만들지 않고 `@iyulab/components` 의 `Locale.namespace()` 를 쓴다. 앱
  하나에서 로케일을 두 번 전환하게 만들지 않기 위해서다.

### Requires

- `@iyulab/components >= 1.23.0` (`Locale.namespace`) — peer 하한을 올렸다.

## 0.7.1

### Fixed

- 🔴**하우스 프리셋이 한 줄도 적용되지 않았다** — `0.7.0` 의 프리셋은 **구조적으로 무효**였다.

  ```css
  /* components/styles/light.css */   :root        { … }   /* 특이도 (0,1,0) */
  /* enterprise/styles/preset.css */  :where(:root){ … }   /* 특이도 (0,0,0) ← 항상 진다 */
  ```

  `:where()` 는 특이도를 0 으로 만들므로 **로드 순서와 무관하게** 기본 시트가 이긴다.
  그리고 프리셋 40선언 중 **기본 시트에 없는 것이 0개**여서, 특이도 0 이 이길 수 있는
  자리가 **하나도 없었다**. 40선언 전부가 사문이었다.

  ⚠**의도는 옳았다** — *"프리셋이 소비자 브랜드를 덮으면 안 된다"*. 그러나 특이도 0 은
  **이겨야 할 상대(기본값)와 지지 말아야 할 상대(브랜드)를 구분하지 못한다.**

  ⇒ **`:root` 로 선언하고 층은 «로드 순서»로 세운다.** 소비자가 이미 통제하는 축이다.

  ```js
  import '@iyulab/components/styles/light.css';   // 기본값
  import '@iyulab/enterprise/styles/preset.css';  // 하우스 — 기본값을 덮는다
  import './brand.css';                            // 소비자 브랜드 — 하우스를 덮는다
  ```

  ⚠**이 실패는 조용했다** — 오류도 경고도 없어, 로드해 보고도 *"프리셋 값이 우리 값과
  우연히 같았나 보다"* 로 읽히는 형태였다.

  ★**계약 테스트는 통과하고 있었다.** `:where()` 로 감싸졌는지를 물었을 뿐 **효력이
  있는지**를 묻지 않았다. 이제 둘을 잰다 — *기본 시트를 이길 수 있는가*(특이도)와
  *이길 것이 있는가*(기본값과 다른 선언이 하나 이상인가).

- **`0.7.0` 을 이미 로드한 소비자**: 렌더가 바뀐다. 그것이 프리셋이 원래 하려던 일이다.
  프리셋을 로드하지 않은 소비자는 **변화 없다**.

## 0.7.0

### Added

- 🆕**하우스 스타일 프리셋** — `@iyulab/enterprise/styles/preset.css`

  ```
  import '@iyulab/enterprise/styles/preset.css';
  ```

  `@iyulab/components` 가 여는 **토큰 축**에 이유랩의 값을 채운 한 장이다. 축(이름·단 수)은
  `components` 소유이고 이 파일은 **값만** 정한다 — 타이포 스케일(LOB 밀도) · 반경(컨트롤 단을
  한 단씩 올림) · 엘리베이션(순수 검정 → **청흑 틴트**, 라이트/다크 각각).

  **계약 셋** (`tests/preset-contract.test.ts` 가 강제한다):
  ⑴ **선택적 로드** — 로드하지 않은 소비자는 `components` 의 중립 기본값을 그대로 받는다.
  ⑵ **`--u-*` 값만 덮는다** — 새 클래스·새 선택자 0개.
  ⑶ 🔴**소비자가 항상 이긴다** — 선택자를 `:where()` 로 감싸 **특이도 0** 으로 선언한다.
     ⚠**이 항목은 `0.7.1` 에서 정정됐다** — 특이도 0 은 기본 시트도 이기지 못해 프리셋이
     통째로 무효였다. 지금은 `:root` 이고 층은 **로드 순서**로 선다.

  **정하지 않는 것**: 중립 램프의 색조(웜/쿨) — 역할 토큰 다수가 중립에서 파생되므로 램프를
  옮기면 대비가 함께 움직인다. 전수 대비 검증 전에는 손대지 않는다. **브랜드 색** — 소비앱의
  몫이고 프리셋은 브랜드를 갖지 않는다.

  ⚠**빌드에 복사 단계가 붙었다**(`scripts/copy-styles.mjs`). `index.ts` 에서 import 하면
  프리셋이 **강제 로드**되어 계약 ⑴이 깨지므로, 번들과 무관하게 파일만 옮긴다.
  복사할 CSS 가 0개면 **실패한다** — 경로가 어긋났을 때 조용히 초록이 되지 않게.

### Fixed

- 🔴**`FormSection` 제목에서 `text-transform: uppercase` 와 `letter-spacing` 제거**

  한글에는 대문자가 없어 `uppercase` 가 **아무 효과가 없고**, 제목에 영문이 섞이면 그것만
  커져 오히려 어수선해진다. 양수 `letter-spacing` 은 **한글 가독성을 떨어뜨린다** —
  라틴 소문자 조판 관례를 그대로 옮기면 안 되는 자리다.
  ⇒ 위계는 **크기·굵기·색** 세 신호로만 만든다. 영문 전용 UI 에서 눈썹 텍스트가 필요하면
  `titleStyle` 로 `--u-text-overline-*` 단을 지정한다(그 단은 영문·숫자 라벨을 전제한다).

### Changed

- **`FormSection`·`FormRow` 의 크기·여백이 토큰 참조가 된다** — `11px`·`20px`·`10px`·`6px`·`8px`
  리터럴이 `--u-text-label-*` · `--u-space-{xs,sm,xl}` 로 바뀌었다.
  ⚠**제목 글자가 11px → 13px(`label` 단)로 커진다.** 종전 값은 스케일 축이 생기기 전이라
  고립돼 있었고, 이제 폼 라벨·표 머리행과 **같은 층**을 쓴다.
  오버라이드 계약(§2-1)은 그대로다 — 호출자의 `style`·`titleStyle` 이 여전히 뒤에 온다.

### Docs

- 헌장에 **L0.5 하우스 스타일 프리셋** 절 신설. ★하우스 값은 **cross-consumer 수요 게이트(§3-1)의
  대상이 아니다** — 게이트가 지키는 것은 **새 표면**이고 프리셋은 표면을 열지 않는다.

### 요구 사항

`@iyulab/components` **1.21.0 이상**(타입 스케일·여백 상단·반경 상단 축).

## 0.6.0

### Fixed

- **매니페스트가 산출물과 어긋나 있었다** — 양방향 모두.

  ```
  제거   @iyulab/components · @iyulab/data-components · @iyulab/modern-app
         → dist 가 셋 다 import 하지 않는다(소스에서도 0건)
  추가   react → peerDependencies
         → dist 가 import 하는데 dependencies·peerDependencies·devDependencies
           어디에도 선언이 없었다
  ```

  ★**미사용 선언의 비용은 디스크가 아니라 사본이다.** `0.x` 캐럿은 마이너를 고정하므로
  (`^0.6.0` 은 `0.8.0` 을 받지 못한다) 소비자가 셸을 올릴수록 중복이 확정된다. 지금은
  `dist` 가 import 하지 않아 로드되지 않지만, 그 무해 판정의 근거는 *"지금 import 하지
  않는다"* 하나뿐이었다 — 커스텀 엘리먼트를 등록하는 패키지가 섞여 있어 한 줄이면
  `customElements.define` 이 두 번 불린다.

  ★**`react` 미선언이 더 위험했다.** 번들에서 external 로 빼 놓고 요구하지 않았으니,
  지금 동작하는 이유는 소비자가 React 앱이라 **우연히 있기 때문**이다.

  ⚠**이 패키지의 렌더·동작은 변하지 않는다** — 제거된 셋은 `dist` 가 참조한 적이 없다.
  `--u-*` 커스텀 프로퍼티는 리터럴 폴백과 함께 쓰므로 코드 의존이 아니다(토큰 시트를
  로드하면 그 값을 따라간다).

  🔴**다만 설치 그래프는 바뀐다.** 이 셋을 `enterprise` 의 전이 의존으로 받아 쓰던
  소비자는 **자기 매니페스트에 직접 선언해야 한다** — 원래 그것이 옳고, 호이스팅에
  기대던 것이 우연히 동작하고 있었을 뿐이다. 엄격한 설치기(pnpm 등)에서는 애초에
  그렇게 쓸 수 없었다.
  ⇒ 업그레이드 시 `@iyulab/components`·`@iyulab/data-components`·`@iyulab/modern-app` 를
  직접 쓰고 있다면 자기 `dependencies` 에 있는지 확인할 것.

- **`odata-query` 가 번들에 인라인돼 있었다** — external 목록에 없어 라이브러리 코드가
  `dist` 에 통째로 실렸고, 그러면서 `dependencies` 에도 선언돼 있었다. 소비자가 같은
  것을 **두 벌** 갖는 형태다. external 로 옮겼다(선언은 그대로).

## 0.5.0

### Added

- ★**[LOB 계층 헌장](./docs/lob-layers.md)** — 이 패키지가 **무엇을 소유하고 무엇을 소유하지
  않는지**를 정한 문서. L0~L3 계층 정의 · 오버라이드 계약 · 카탈로그 등급 · 의존 방향.

  - **오버라이드 계약이 1급 조항이다** — 소비자가 조정할 경로가 없는 패턴은 이 계층에
    들어올 수 없다. 경로가 없으면 소비자는 통째로 복제하게 되고, 그러면 이 계층은
    **쓰기 전보다 나쁜 상태**를 만든다.
  - **카탈로그에 등급을 매긴다** — 반복이 실측된 것만 착수하고, 요청만 있는 것은
    **만들지 않는다**고 명시한다.
  - ★**프레임워크 전제를 못 박는다**: *L0·L1 은 프레임워크 중립, L2·L3 는 React 전제.*
    적어 두지 않으면 다른 프레임워크의 소비자가 나타났을 때 아무도 결정하지 않은 채
    갈라진다.

  헌장과 구현이 어긋나면 실패하는 대조 테스트 6건이 함께 있다 — 계층 헌장의 실패 형태는
  위반이 아니라 **표류**이고, 표류는 문서와 코드가 각각 멀쩡해 보여서 눈에 띄지 않는다.

- **`FormRow`에 `columns`** — 2열 고정이 맞지 않을 때 컴포넌트를 복제하지 않고 조정한다.
  (README 가 *"로드맵에 있다"* 고 적어 두었던 항목이다.)
- **`FormSection`·`FormRow`에 `className`·`style`**(+ `FormSection`의 `titleStyle`) —
  기본값 **뒤에** 병합되므로 필요한 항목만 골라 덮을 수 있다.
- **`FormSection`의 `title`이 `ReactNode`를 받는다** — 종전에는 `string` 이라 배지·아이콘을
  붙이려면 블록을 복제해야 했다.

### Fixed

- **폼 블록 제목이 브랜드 색을 따르지 않던 문제 수정** — 팔레트(`--u-blue-600`)를 직접
  읽고 있었다. 소비자가 브랜드를 맞추려면 팔레트를 덮는 수밖에 없었고, 그러면 *진짜
  파랑*이 필요한 곳까지 함께 바뀐다. 역할 토큰(`--u-primary-color`)에서 파생하도록 고쳤다.
- **폼 블록 제목의 대비 부족 해소** — 11px 텍스트가 3.68:1 로 WCAG AA(4.5:1)에 미달했다.
  한 단 어두운 파생값(4.85:1)으로 교체했다.

## 0.4.0

### Added

- **`createODataService(config)`** — OData v4 + custom REST 서비스 팩토리. 여러 소비앱이 동일하게 재작성하던 CRUD 래퍼(401 세션 처리, 에러 메시지 추출, 성공 토스트, 204 빈-바디 안전 파싱)를 범용 primitive 로 승격.
  - 도메인/로케일 요소는 주입: `baseUrl`, `onUnauthorized`, `notify`(토스트 훅), `messages`(문구 오버라이드 — 기본 영어), `formatError`(에러 포매팅 훅).
  - 반환 서비스: `odataGet/odataGetById/odataCount/odataPost/odataPostQuiet/odataPatch/odataDelete`, `apiGet/apiPost/apiPatch/apiDelete`, `fetchRaw`, `odataUrl`/`apiUrl`, `sourceDefaults`(flex-table `useODataSource` 용).
  - `ApiError`(HTTP status 포함) 함께 export.
- 신규 의존성: `@iyulab/http-client`, `odata-query`.
- `vitest` 단위 테스트 인프라 도입(모노레포 표준) — `npm test`.
- **`createAuthClient<TUser, TCredentials>(config)`** — 쿠키 세션 인증 클라이언트 팩토리. `fetchMe`(미인증 시 null)/`login`/`logout` 흐름을 승격. 사용자·자격증명 형태는 제네릭으로 앱이 정의(라이브러리는 URL 호출만). 원시 `fetch` 사용(401 인터셉터 우회 — null 이 곧 미인증 신호). `getPermissions` 지정 시 성공/로그아웃마다 권한 store 자동 갱신.
- **권한 스냅샷 store** — `createPermissionStore(initial?)`(factory) + 기본 싱글톤 바인딩 free 함수(`setPermissions`/`getPermissions`/`hasPermission`/`hasAnyPermission`/`hasAllPermissions`/`clearPermissions`). 권한 코드는 불투명 문자열, false-by-default, `subscribe` 반응형 훅. React·Lit 공통.

### Notes

- 토스트/문구·사용자 형태·권한 코드를 모두 주입/제네릭으로 받으므로 서비스·인증 모듈은 `@iyulab/modern-app` 에 의존하지 않는다(순수·테스트 용이).
- 도메인 요소(엔티티 목록, `isPortalUser` 같은 도메인 판정, 권한 코드 상수)는 라이브러리에 넣지 않고 앱 adapter 에 남긴다.
