# What's New

Newly added features and improvements to the starter kit are listed here.

## 2026-10-18 — v13.8.7

### Security

- **API documentation's try-it panel handles saved credentials more safely.** The kit now requires `lvntr/api-dock` 0.0.9, which keeps tenants from seeing each other's saved try-it credential, stops a switched profile's token from going to the previous profile's server, and keeps a deleted profile deleted. Update with `composer update lvntr/laravel-starter-kit -W` (or `composer sk-update`); without `-W` you stay on the previous kit. See [update.md](https://starter-kit.lvntr.dev/docs/update#1-update-composer-package).

### Fixed

- **Screen readers now read the table's column count and active filter count.** The columns and filter buttons above a datatable announced only "Columns" and "Filter", without the numbers shown on them.

### Changed

- **`config/api-dock.php` is in step with api-dock 0.0.9.** It adds the `try_it.proxy` option (off by default) and updated notes; nothing behaves differently. `php artisan sk:update` refreshes the file unless you changed it.

## 2026-10-13 — v13.8.6

### Fixed

- **Running `sk:install` a second time no longer breaks on Telescope or Pulse.** After an interrupted install, the next run copied their migrations again and stopped at "Running migrations" with a "table already exists" error, even on an empty database. The installer now sees that they are already there. If an earlier run already left two copies of `create_telescope_entries_table` or `create_pulse_tables` in `database/migrations`, delete the newer one.
- **New apps no longer start with a high-severity `npm audit` warning.** The bundled `package-lock.json` now installs `source-map-js` 1.2.2, which closes [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q). In an existing app, run `npm audit fix`.

### Changed

- **`sk:install` checks the database before it goes on.** If it cannot connect, the install stops right after the database questions and shows the reason, instead of failing much later with a database error. Create the database or fix the `DB_*` values in `.env`, then run `php artisan sk:install --resume`. An install can no longer be run without a working database. See [install.md](https://starter-kit.lvntr.dev/docs/install).

## 2026-10-06 — v13.8.5

### Fixed

- **Uploading a file with the same name no longer replaces the earlier one.** A new avatar with the same file name as the old one could leave the profile without a picture, and two form attachments with the same name ended up as one file. Uploads are now stored on disk under a generated name, while the screen still shows the original file name.

### Added

- **Update the kit with one command: `composer sk-update`.** It runs `composer update lvntr/laravel-starter-kit -W` and then `php artisan sk:update`, so `-W` can no longer be forgotten. The script is added to your `composer.json` the next time you run `php artisan sk:update`. See [update.md](https://starter-kit.lvntr.dev/docs/update#1-update-composer-package).
- **`sk:update` and `sk:doctor` tell you when Composer kept you on an older kit.** Instead of updating silently to nothing, they show the newer version and the command that fixes it.

### Changed

- **The changelog and upgrade guide now come with the package.** You (and your AI agent) can read what each release changed in `vendor/lvntr/laravel-starter-kit/CHANGELOG.md` and the manual upgrade steps in `vendor/lvntr/laravel-starter-kit/docs/UPGRADE.md`. Run `php artisan sk:update` so the AI skills point there.

## 2026-10-03 — v13.8.4

### Fixed

- **The file manager keeps you where you are.** Clicking through folders quickly no longer shows an earlier folder's files, sorting in Favorites or Trash stays in that view, and a failed upload keeps its error card until you close it.
- **File manager tiles are easier to read.** The file type label (such as `PNG`) is no longer hidden behind the selection box, and the buttons that appear on hover stay visible over white images.
- **`sk:update` now runs the kit's new database changes.** Updating could leave out a new table the kit needs, so the active share links list showed "A server error occurred." until you ran `php artisan migrate` yourself.
- **The AI skills shipped with the kit match the code again.** Agents were told a few things that are not true — that any `LogicException` becomes a 422, that `useDialog()` takes a `refreshKey`, that `sk:seed-permissions --fresh` is the routine step (it resets every role's permissions), and that `site:install` is harmless (it drops all tables). These are corrected.
- **Password fields work properly with screen readers and password managers.** On the sign-in, registration, password reset and password-change screens, clicking a password label now focuses the field, screen readers read the field's name and its error, and password managers get the right "current password" / "new password" hint. FormBuilder password fields with the strength meter link their error text the same way. Run `php artisan sk:update` to get the updated pages; pages you have edited are left alone.
- **Screen readers announce errors and required state on more form fields.** Number, code (OTP), date, dropdown, multi-select, checkbox and switch fields in admin forms now read out their validation error and "required" when focused.
- **More of the admin works with screen readers.** The dark-mode button on the sign-in screen, the row selection checkboxes and ID buttons in tables, and the rich-text editor now announce what they are, and the table filter button is announced as "Filter" instead of "Clear Filters".
- **"Log out other browser sessions" now really logs the other devices out.** On some session setups the other browsers stayed signed in, and a "remember me" cookie on another device could sign the account back in. Both are fixed.
- **Tables no longer make the page scroll by a few pixels.** With a full page of rows, an admin page could scroll a tiny bit on a laptop screen because the "rows per page" box was taller than the page buttons next to it. It is now the same height and the pagination bar is slimmer. Run `php artisan sk:update` to get the updated theme; files you have edited are left alone.

### Added

- **The AI skills now cover 13.7–13.8:** updating with `-W`, the `package.json` merge, `sk:doctor`'s Kit Dependencies check, `sk:redact-activity-secrets`, the new `make:sk-domain` flags, editor button links, datatable notices, bulk selection and the Hetzner / Amazon S3 drivers. Run `php artisan sk:update` to get them.
- **Full API references for the AI skills.** Every FormBuilder / DatatableBuilder / TabBuilder method, composable and component, plus the backend `ApiResponse` / `ApiException` / `ActionPipeline` / `DatatableQueryBuilder` API and the global helpers. Agents read them only when they need an exact method, so everyday work stays fast.
- **CI now also checks the kit's built-in admin pages.** The pages, composables and plugins that run from `vendor/` are linted and type-checked in CI like the component library (`npm run lint:lib`, `npm run typecheck:lib`).
- **Two-factor sign-in is now tested in a real browser.** CI turns two-factor authentication on from the profile page, signs in again through the challenge with a recovery code, and turns it off.
- **See and revoke a file's active share links.** The share dialog in Files now has an "Active Share Links" drawer listing the links still valid for that file, each with a revoke button. Run `php artisan migrate` after updating (`sk:update` does it for you); links created before the update are not listed but still expire on schedule.
- **Saved table views.** Tables that turn it on (the Users list does) get a bookmark button: save the current search, sorting, filters and columns under a name like "Active customers" and bring them back with one click. Views are kept in your browser.
- **See what an update skipped.** `php artisan sk:update --dry-run --report=storage/sk-update.json` writes a JSON report; every file you changed is listed next to the kit's new version, so you can compare them.
- **Updates are tested on an upgraded app.** CI now installs the previous release, customizes a few files, updates to the new one, and runs the browser tests against the result — including automatic accessibility checks.
- **Sorting in Favorites and Trash.** Both views get the sort menu (name, size, date, ascending or descending). Trash still opens with the most recently deleted items first.

### Changed

- **Better text contrast in the default theme.** Buttons, links and muted text in light mode are darker so they meet WCAG AA contrast: the default blue is now `#1976d2`, red, green and blue buttons use deeper shades, and orange (warning) buttons use dark text. An accent color picked in Settings is applied one shade darker too. Run `php artisan sk:update` to get the updated theme; files you have edited are left alone.
- **Changing or resetting your password signs you out everywhere else.** Other browsers and devices are signed out the next time they load a page; the device you used stays signed in. When an admin sets a new password for a user on the Users screen, that user is signed out everywhere. Upgrading does not sign anyone out.

## 2026-10-03 — v13.8.3

### Fixed

- **`sk:update` keeps the scripts you added to `package.json`.** Your own scripts stay; the kit's scripts are refreshed. `sk:update --dry-run` now shows the `package.json` change it would make.

### Added

- **`sk:doctor` warns when your `composer.json` lists `lvntr/api-dock` itself.** The kit already installs it, and that entry can hold the kit back on an older version. The warning prints the command that fixes it.

### Changed

- **Update with `composer update lvntr/laravel-starter-kit -W`.** Without `-W`, Composer can silently stay on an older kit (for example 13.8.1 instead of 13.8.2). Stuck? See [update.md](https://starter-kit.lvntr.dev/docs/update#1-update-composer-package).

## 2026-10-01 — v13.8.2

### Changed

- **Wayfinder stops generating the unused `@/actions` helpers.** They produced two folders whose names differed only in letter case, which broke `vue-tsc` on case-insensitive setups. Route helpers under `@/routes` are unchanged; regenerate with `php artisan wayfinder:generate --skip-actions`.
- **api-dock attributes use the `Lvntr\ApiDock` namespace** (api-dock 0.0.8). Existing imports with the old spelling keep working.

## 2026-09-25 — v13.8.1

### Added

- **The rich text editor can add buttons.** When links are enabled, a new toolbar button turns the selected text — or a label you type — into a button that opens a URL, in three styles — primary, secondary and outline — and in any color from the editor palette. Content stays plain HTML, so admin pages show it as a button and a mobile app can render it as its own native button; an app that does not know about buttons still shows a working link. Run `php artisan sk:update` to get the button styles.

### Fixed

- **`sk:install` and `sk:update` show the version you actually have installed.** Their header always said `v13.7.x`, whatever the installed release was.

## 2026-09-23 — v13.8.0

### Added

- **Hetzner Object Storage and Amazon S3 can now be chosen in Settings → Storage.** Next to Local and DigitalOcean Spaces, the storage driver picker now offers Hetzner (with a Falkenstein / Nuremberg / Helsinki region picker) and Amazon S3. Existing installs get the new `hetzner` disk added to `config/filesystems.php` by `php artisan sk:update`. The Hetzner secret key is stored encrypted, like the other storage secrets. You no longer type `https://` for the endpoint or URL, choosing a region fills in the endpoint, entering a bucket name fills in the URL, and the region lists are searchable.

- **The rich text editor can show and edit the content's HTML, and has more formatting tools.** A `</>` button switches the editor to its HTML source — edits count immediately, so saving with the source view open keeps them, and any tag the editor cannot keep is named under the text box as you type — and a fullscreen button gives the content the whole screen (Esc leaves). The toolbar also gains underline, highlight color and clear formatting; the `standard` preset adds task lists and justify, and `full` adds H4, inline code, sub/superscript, font / size / line-height pickers and YouTube videos. Run `php artisan sk:update` and `npm install` to pull the new editor packages.

### Security

- **Only YouTube video embeds survive in saved editor content.** Any other `<iframe>` is still removed on save, and the new font styles only accept plain values.
- **Secret settings stay encrypted even if your `config/settings.php` is older than the kit.** A newly added secret setting used to be saved unencrypted when the app's published settings config did not list it yet; the kit's built-in list now always applies.

## 2026-09-22 — v13.7.6

### Changed

- **The log viewer now shows the newest entries at the top.** Opening a day's log file used to start you at the oldest entries of the day, so reaching the error you actually came for took click after click on "load more". The list now runs newest to oldest, and **load more** appends the next older page below it. Multi-line stack traces and non-Laravel-format lines keep showing up as before.

## 2026-09-16 — v13.7.5

### Added

- **Tables can now show a message bar between the search row and the column headings** — a full-width notice for things like "this list is read-only" or "your import failed". Pick a tone (`info`, `success`, `warn`, `danger`, …), optionally an icon and a dismiss button. For a message that changes while the page is open, a `#message` slot takes over.
- **The table toolbar's `#toolbar-end` slot works now.** Anything you put in it used to disappear; it now appears on the right, after the filter and columns buttons.

### Changed

- **Inline filter pills now sit with the filter and columns buttons on the right**, instead of directly beside the search box — the search box no longer gets pushed around as filters are added, and the spacing around the search row is even.

## 2026-09-14 — v13.7.4

### Fixed

- **Bulk role deletion could delete an equal-rank peer role that the single-record delete already refused**, because it re-implemented the rank check with a slightly looser comparison. Both paths now share one rank check. Scaffold-side (`stubs/`): reaches an existing install only via `sk:update` — a customized copy of `BulkDeleteRoleAction.php` needs the fix re-applied by hand.
- **Registration now goes through the same rate limit and Turnstile CAPTCHA the login form already enforces**, closing a gap that let an automated client create accounts without solving it. Scaffold-side: reaches an existing install only via `sk:update` — a customized `FortifyServiceProvider.php` needs this applied by hand.
- **Resetting a password now signs the account out everywhere** — every API token and every other active session are revoked, not just the one that requested the reset. Doesn't yet cover an in-app password change while logged in or an admin setting your password; those still leave old sessions live.
- **The file manager's trash could return a working preview and a real download link for another user's deleted file**, and a create-only uploader could restore a file that wasn't theirs. Both are now blocked.
- **Uploading into a folder you don't have write access to is now rejected**, instead of silently going through.
- **Moving a folder into its own subfolder is now blocked**, instead of corrupting the folder tree.
- **The rich-text editor's HTML sanitizer could be tricked into keeping a dangerous link scheme** (e.g. `javascript:`) by hiding a control character inside it. It now normalizes the value the way a browser does before checking it.
- **Previewing a local HTML file in the file manager no longer runs it as a live page.** It now renders in a fully sandboxed frame, and the "open in new tab" option is hidden for that file type since opening it in a browser tab would have been less safe than the sandboxed preview.
- **`encryption:key` now refuses to rotate on macOS if it can't confirm your `.env` file's permissions survived the rewrite**, instead of rotating and leaving that uncertain. Pass `--allow-acl-loss` to proceed anyway once you've checked.

### Changed

- **The "Debug Mode" badge now follows the role, not the environment — so a forgotten `APP_DEBUG=true` is visible exactly where it costs.** `HandleInertiaRequests` shared `appDebug` as `false` for the whole of production, which meant the header's only warning that debug output was still on went dark on the one server where leaving it on matters. The gate is now the audience instead: `appDebug` is `config('app.debug')` AND the viewer holding `system_admin`, in every environment. A system admin sees the badge on production; nobody else ever receives the flag, so the fingerprinting concern the environment check was written for still holds. `appEnv` keeps its production `null` — its "Dev Mode" badge only ever renders on `local`. The `roles` relation is already loaded for the `auth` share, so the check costs no extra query. `HandleInertiaRequests.php` is a published file — an app that edited its own copy keeps it and applies the change by hand, per [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).

## 2026-09-10 — v13.7.3

### Fixed

- **`sk:update` now tells you how to recover from the `ERESOLVE` its own dependency bump causes.** 13.7.0 moved the frontend toolchain onto current releases, `@tiptap/*` included (`^3.27.1` → `^3.31.3`). The merge into the app's `package.json` lands fine, but a `package-lock.json` carried over from 13.6.x still pins the old graph — and because every `@tiptap/extension-*` declares an *exact* peer on `@tiptap/core`, npm cannot lift half the family and aborts with `ERESOLVE`. Both commands printed only `npm install && npm run build`, which is the command that fails. `sk:update` now detects that the merge moved a constraint the app already carried and prints the lockfile recovery line alongside it; `sk:upgrade` prints the same line when its own `npm install` step fails. The recovery is documented in [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) — `rm -rf node_modules package-lock.json && npm install`, never `--force` or `--legacy-peer-deps`, which install a `@tiptap/core` the surrounding extensions were not built against.

## 2026-09-08 — v13.7.2

### Fixed

- **The back button no longer sits a line above the create button it stands next to.** `.admin-page-header` carries the page strip's own `mb-6`, and aura stripped it per host — one rule each for `.sk-card__head--page`, `.sk-card__actions` and `.fm-sidebar__page-header`. The datatable toolbar's action row, which hosts the header whenever the table has a title of its own, was never on that list, so its 24px bottom margin pushed the header off the row's centre line. The reset now hangs off the `data-sk-page-header` marker every host sets, so it applies to all of them at once and the next host added inherits it instead of shipping the same gap; `FileManagerSidebar` gained the marker it was missing.

## 2026-09-08 — v13.7.1

### Fixed

- **The back button in a datatable toolbar now sits before the toolbar's actions, matching every other card.** `SkDatatable` drew the hosted page header (the layout's back button and page actions) at the very end of `.sk-dt-toolbar__actions`, while `SkCard` — the host `SkForm` and every plain card go through — drew it first. A screen carrying both therefore showed `[+ Create] [← Back]` on the table and `[← Back] [Save]` on the form. The header now renders first in the datatable toolbar too, so back stays a navigation control to the left of the primary action instead of reading as one. The toolbar's action container also carries the `data-sk-page-header` marker `SkCard` already set, so consumer CSS can target both hosts with a single selector.

## 2026-09-06 — v13.7.0

### Added


- **A dedicated `DATA_ENCRYPTION_KEY`, independent of `APP_KEY`, now protects sensitive settings values (`mail.password`, `storage.spaces_secret`, `storage.aws_secret`, `turnstile.secret_key`, `postman.api_key`, `apidog.access_token`) and 2FA secrets/recovery codes.** Previously all of this data was encrypted with `APP_KEY`, so a routine `php artisan key:generate` on a server migration made it silently unrecoverable — `SettingService` swallowed the resulting `DecryptException` and returned `null` instead of erroring. Three new commands manage the new key: `encryption:key` generates it and preserves the old key in `DATA_ENCRYPTION_PREVIOUS_KEYS`; `encryption:rekey` re-encrypts existing rows onto the new primary key without ever touching a row it cannot decrypt; `encryption:health` reports whether the previous-key list is safe to clear, and `php artisan sk:doctor` gained a matching `Data Encryption Key` check. Adoption is opt-in — an install that runs none of this keeps working exactly as before, byte-for-byte, and a fresh `sk:install` now generates the key automatically. See [Data Encryption](https://starter-kit.lvntr.dev/docs/encryption) and the [server migration runbook](https://starter-kit.lvntr.dev/docs/server-migration).
- **`SkForm` gained a `reload()` method and a `.reloadOnDataUrlChange()` builder flag.** `reload()` (exposed via `defineExpose`) lets a host re-fetch `dataUrl` on demand — a "Refresh" button, a sibling save event — without remounting the form. `.reloadOnDataUrlChange(true)` opts a form into refetching automatically whenever its `dataUrl` prop changes after mount (e.g. a dialog reused for a different record id); the default stays mount-only so a form whose config is rebuilt on every parent render doesn't refetch on every rebuild. See [FormBuilder API](https://starter-kit.lvntr.dev/docs/formbuilder#form-builder-api).
- **`FB.fileUpload()` gained `.deferExistingRemoval()` and drag-and-drop.** `.deferExistingRemoval(true)` changes removing an already-saved file from an immediate `DELETE /media/{id}` to a deferred one: the item only leaves the field's keep-list, and deletion happens on save via `Lvntr\StarterKit\Traits\HasMediaCollections::syncMediaCollection()`. The upload field's drop zone now also accepts files dragged onto it, going through the same `accept`/`maxFileSize`/`fileLimit` validation as the picker button. See [File Upload Field API](https://starter-kit.lvntr.dev/docs/formbuilder#file-upload-field-api).
- **The `@lvntr/components` package library is now linted in CI** via a new `lint:lib` root script (`eslint --config stubs/eslint.config.js resources/js/components/Lvntr-Starter-Kit`), so a lint regression in the shared component library is caught the same way stub-side lint already is.
- **`TabIconColor` and `TabBadgeSeverity` are now exported from the TabBuilder core barrel** (`@lvntr/components/TabBuilder/core`), matching the already-exported `TabBuilderConfig`, `TabItemConfig`, and `TabLayout` — a consumer typing against a tab's icon color or badge severity no longer needs to reach into the internal `./types` module directly.
- **`TB.tabs()` gained five chainable options for panel mounting and URL behavior: `.lazy()`, `.keepAlive()`, `.history('push' | 'replace')`, `.urlMode('server' | 'client')`, and `.syncUrl(boolean)`.** `.lazy()` mounts only the active panel (PrimeVue's own lazy mode on horizontal layout) while `.keepAlive()` keeps every panel mounted and hides inactive ones, preserving per-tab state; `.history()` controls whether a switch replaces the current history entry (default) or pushes a new one; `.urlMode('client')` rewrites the URL with no server request instead of the default Inertia visit; `.syncUrl(false)` drops URL sync entirely. See [tabs.md](https://starter-kit.lvntr.dev/docs/tabs).
- **`SkTabs` now supports `v-model` and emits a `change` event.** The optional `modelValue` prop two-way-binds the active tab key in both URL and local mode — a URL deep link wins over a different incoming `modelValue` on mount; `change` fires on every switch after mount (not the initial mount) with `{ key, previousKey, tab }`.
- **`SkTabs` gained an `empty` slot**, rendered alone — no sidebar, no tab strip — when no tab is selectable: every tab is filtered out by `.permission()`/`.role()`/`.visible()`, or every visible tab is `.disabled()` (which used to leave an all-disabled strip with no active panel).
- **Vertical `SkTabs` is now a proper ARIA tablist.** The sidebar nav is `role="tablist"`/`aria-orientation="vertical"`, each tab button is `role="tab"` with `aria-selected`/`aria-controls`/`aria-disabled` and roving `tabindex`, and the panel is wrapped in `role="tabpanel"`; Arrow Up/Down, Home/End move focus between enabled tabs and Enter/Space select.
- **`TabsBuilder.build()` now validates duplicate tab keys and returns an immutable snapshot.** A duplicate key throws in development builds and logs a `console.error` in production instead of staying silent; `TabItemBuilder.build()` also now rejects a whitespace-only key, not just a missing one; every `build()` call returns a fresh copy of the config and its tabs, so a later `.addTabs()` on the same builder or mutating the returned config can no longer affect an already-built config.
- **`TabPanelMode`, `TabHistoryMode`, `TabUrlMode`, `TabChangePayload`, and `SkTabsExposed` are now exported from the TabBuilder core barrel** (`@lvntr/components/TabBuilder/core`), alongside the existing `TabBuilderConfig`, `TabItemConfig`, `TabLayout`, `TabIconColor`, and `TabBadgeSeverity` exports.
- **`useUrlTab()` now accepts a `ref` or a getter for its `tabs` argument, in addition to a plain array, and a new `{ history: 'push' | 'replace' }` third argument.** The value is read through `toValue()` on every access; `history: 'push'` gives each switch its own history entry instead of the default `'replace'`.


- **Static analysis (Larastan/PHPStan, level 5) now runs against `src/` in CI**, alongside Pint. `phpstan.neon` scans `stubs/app` so the package's `App\*` references (the consumer-owned classes the kit assumes exist, e.g. `App\Models\User`) resolve without a full consumer app; `phpstan-baseline.neon` snapshots the pre-existing findings so the gate holds only new code to the bar. Run locally with `composer analyse`. The baseline has since been narrowed from 259 to 238 findings as fixable issues were triaged out of it.
- **`make:sk-domain` gained a `--with-permissions` opt-in flag** (also selectable via `--with=permissions`). It registers the new domain's resource — all default abilities — in `config/permission-resources.php` along with an English display name; the Turkish label and role assignment are deliberately left for you to fill in, since neither can be inferred safely from the domain name alone. See [Artisan Commands](https://starter-kit.lvntr.dev/docs/artisan-commands).
- **A Playwright admin smoke suite now runs in CI.** `tests/e2e/specs/admin-smoke.spec.ts` drives a real fixture app — scaffolded by `scripts/bootstrap-fixture-app.sh` and seeded via `scripts/e2e/fixtures/E2EAdminSeeder.php` — through login, domain CRUD and settings screens; a new `e2e` GitHub Actions job builds the fixture and runs the suite headless on every push. Run it locally with `npm run test:e2e`. See [testing-e2e.md](testing-e2e.md).
- **`sk:install` gained an opt-in `--modules=telescope,pulse,horizon,sentry` flag for optional observability recipes.** Left off, a TTY install prompts for which of Laravel Telescope, Laravel Pulse, Laravel Horizon, and Sentry to add; a non-interactive run (or an empty `--modules=`) skips the step entirely, so nothing changes for an install that ignores the flag. Each selected recipe runs its own `composer require` plus post-install commands as a best-effort step — one recipe's failure doesn't abort the install or block the others, and any recipe that didn't fully install is listed at the end with a note to run it by hand.
- **The kit's API documentation now runs on `lvntr/api-dock`, a new dependency, replacing Scramble's own UI as the reading surface.** `sk:install` publishes `config/api-dock.php` on every install, and api-dock mounts its panel at `/api-dock` (the raw document at `/api-dock/spec`), documenting the same OpenAPI document the kit already generates from route attributes. Unlike the Scramble surface it replaces, the panel is gated behind the seeded `api-docs.read` permission — previously that docs route had no permission wired to it at all. That gate does not depend on the install step running: api-dock's packaged default serves the panel, `/api-dock/spec` and the try-it proxy behind `['web']` alone, so while `api-dock.middleware` is still that untouched default the kit stacks `['web', 'auth', CheckApiDocsAccess::class]` on it — and disables api-dock entirely when the middleware class is not in the app yet, rather than serving it anonymously. Four new commands come with it: `api-dock:export` snapshots the document, `api-dock:diff` compares it against a stored baseline, `api-dock:sync` pushes it to Postman/Apidog, and `api-dock:agent-guide` generates AI-oriented artifacts — an `llms.txt` plus an MCP tool export (opt-in, and only for operations carrying an `AiTool` attribute). See [api.md](https://starter-kit.lvntr.dev/docs/api).
- **`sk:update` and `sk:doctor` now detect a kit-required Composer package the app never installed.** `sk:update` prints the missing packages and, on a real TTY without `--dry-run`/`--no-interaction`, asks before running `composer update lvntr/laravel-starter-kit -W`; `--no-interaction` and `--dry-run` only print the command. `sk:doctor` reports the same condition.
- **The kit's own auth and definitions API operations now carry `lvntr/api-dock`'s AI metadata attributes** (`AiHint`, `AiPitfall`, `AiExample`, `AiTool`, `ApiFeature`). `login` documents that all three of its outcomes return 200 and that only the presence of `token` signals a completed login; `register` documents its deliberate 403 (not 404) when registration is disabled. Only `me` and the definitions lookup carry `AiTool`, since they're the only side-effect-free reads. Metadata only — no behaviour or route changed. See [api-ai-metadata.md](https://starter-kit.lvntr.dev/docs/api-ai-metadata).

### Security


- **A FileManager file on a local or public disk no longer resolves to a permanent, unauthenticated public URL.** It previously fell back to `Media::getUrl()` whenever the disk doesn't support temporary/signed URLs — a link that bypasses authorization entirely and keeps working after a permission revoke or a move to trash. It now falls back to a new authorized `files.preview` route, gated the same way `files.download` already is; S3 and any disk with temporary-URL support is unaffected. Only a conservative allowlist of passively-rendered types (images, audio, video, PDF, plain text, CSV) is served inline through it — everything else is sent as an attachment. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).
- **Logging out — and revoking a user's access — no longer leaves a live OAuth refresh token behind.** A refresh token outlives its access token by design, and `RevokeUserAccessAction` only revoked access tokens that were still marked live, so an account already logged out or revoked once could still hold a refresh token bound to that revoked access token and mint a new one on demand. Both `RevokeUserAccessAction` and the stub `LogoutUserAction` now revoke the refresh token bound to a token first and the access token second, through a new `RevokesOAuthCredentials` trait. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) if you customised `LogoutUserAction`.
- **Copying a file in the FileManager no longer bypasses the storage quota.** Only the upload path checked `storage.quota` against usage; `CopyFileAction` created a full new blob per copy with no ceiling. It now runs the same quota check before the physical copy and rejects with the same `quota_exceeded` message the upload path uses.
- **Rotating `DATA_ENCRYPTION_KEY` no longer silently drops — or gains — a file-specific ACL on `.env`.** A `setfacl`/`chmod +a` grant on `.env` is invisible to `fileperms()`, so it did not survive the atomic rename even though owner/group/mode did. `encryption:key` now carries the ACL over and verifies it by reading it back, refusing the rotation when it cannot. The mirror case is also closed: a directory-level inheritance rule can put an ACL on the temp file that `.env` never had, before the key is even written into it — that inherited entry is now normalised away first. Either mismatch is refused; a new `--allow-acl-loss` flag downgrades the refusal to a warning for an operator who will reconcile it by hand.
- **`stubs/package.json`'s `@tiptap/*` packages moved from `^3.22.4` to `^3.31.3`** to pick up upstream fixes; `stubs/package-lock.json` regenerated. No used API changed across that range.

- **File uploads are now checked against an extension allowlist derived from their accepted MIME types, and `media-library`'s active-content extensions are now blocked unconditionally.** The FileManager's public disk serves a stored file back under its own client name, and the existing `mimetypes:` rule only looks at the sniffed content — so a file whose bytes matched an accepted type but whose client name ended in `.html` was previously stored and served as active content. `UploadFileRequest` now derives an `extensions:` rule from a new `mimeExtensionMap()`, reusing the existing `sk-file-manager.errors.upload_invalid_type` message, so an upload named e.g. `payload.html` is rejected with a 422 even though its bytes are a valid accepted type; the shipped `UploadAvatarRequest` stub carries the matching `extensions:jpg,jpeg,png,webp` rule too — existing installs get it via `php artisan sk:update`, or by adding the rule by hand. `media-library.disallowed_extensions` is now hardened unconditionally, with no opt-out, to also block `html`, `htm`, `xhtml`, `xht`, `svg`, `svgz`, `xml`, `xsl`, `xslt`, `js`, `mjs`, `hta`: Spatie checks every dot segment of the client name, not only the last, so a double extension such as `name.html.pdf` is refused at this layer even though `.pdf` alone would pass the request-level rule. Spatie's own `FileNameNotAllowed` now maps to a 422 ("The uploaded file name is not allowed.") instead of surfacing as an opaque 500. The same per-segment check now also runs inside `UploadFileRequest` itself (a field-level 422 instead of the exception path, and independent of the installed media-library version), and **renaming a file can no longer change its extension**: `RenameFileRequest` requires the new name to keep the current extension (case-insensitively) and refuses any blocked segment, because the media library physically renames the stored file on `file_name` change and a rename to `.html` would otherwise turn a validated image into active content. MIME types outside `mimeExtensionMap()` (admin-added PPTX, RAR, Markdown, …) now resolve their extensions from Symfony's MIME database instead of the raw subtype, and `text/plain` accepts `.txt`, `.log`, `.md`, `.csv`, `.json`, `.ini`, `.yml` and similar text names. Spatie's `FileIsTooBig` (its own `media-library.max_file_size`, 10 MB by default, independent of the request `max:` rule) now maps to a 422 too, and the shipped `SettingsServiceProvider` stub keeps that ceiling in step with the FileManager `max_size_mb` setting. The kit now requires `spatie/laravel-medialibrary` `^11.23` (per-segment blocking shipped in 11.23.0), so `composer update` pulls a build that enforces the list. See [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).
- **The settings cache now stores ciphertext for encrypted values instead of decrypted plaintext.** `SettingService::allGrouped()` cached the fully-decrypted row set for an hour under the `settings` key, so any cache backend (Redis, a file store, a store shared with another app) held `mail.password`, storage secrets and similar values in plaintext for that hour. Raw rows are now cached under a new `settings:v2` key and decrypted after every cache read; the legacy `settings` key is forgotten the first time the new snapshot is built, and `php artisan encryption:rekey` clears `settings:v2` after re-encrypting rows onto the new key. An unreadable encrypted row (a rotated or wrong `DATA_ENCRYPTION_KEY`, corrupted ciphertext) is now retried — and logged — on every read instead of being cached as `null` for the full hour, so restoring the correct key takes effect on the next request rather than after the cache expires.
- **The API login endpoint now has its own per-account rate limit, on top of the existing per-IP one.** `POST /api/v1/auth/login` moves to a new `api-login` limiter: 5/min per IP (unchanged) plus a new 3/min per email address, so an attacker spreading guesses for one account across many IPs is now stopped too. This limiter can never be relaxed by the `auth.login_throttle` setting, which only affects the separate web login route. It is registered by the package itself, not by the published `FortifyServiceProvider`, so it can never go missing when `sk:update` refreshes one published file but preserves another. If you customised the published `routes/api/public-api.php`, see [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) to port the route change by hand. Because the three `/api/v1/auth/*` endpoints used to share one unnamed per-IP bucket and `login` now has its own, the group's combined per-IP allowance goes from 5 to 10 requests a minute; the login route itself is unchanged at 5/min per IP.
- **A new opt-in flag can serve your admin panel's script tag with a per-request CSP nonce instead of `'unsafe-inline'`, closing the door on an injected inline `<script>` executing.** Set `STARTER_KIT_CSP_NONCE=true` to turn it on. It defaults to `false` because it requires the `nonce="{{ Vite::cspNonce() }}"` attribute on your published `resources/views/app.blade.php` — without it, turning the flag on breaks the panel's theme script silently. A brand-new `sk:install` already has both pieces in place. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) before enabling this on an existing install.

### Changed


- **A titled `SkDatatable` toolbar now splits into two rows: the heading and its buttons on top, search and filters underneath.** `DB.table().title()` put the heading, the search box, the inline filter pills, the filter/columns buttons, the create button, the `#toolbar` / `#toolbar-start` slots and — under aura — the hosted page header on a single line, which ran out of room as soon as a table carried more than a filter or two. The head row now matches `.sk-card__head` padding exactly (title/subtitle on the left, create button and toolbar slots on the right), so a titled table reads like an ordinary card heading, and search, inline filter pills and the filter/columns group wrap onto their own line beneath it. A toolbar with no title is unchanged — one row, actions last.
- **Under aura, a card-surfaced `SkDatatable` now draws the page heading inside its own toolbar instead of a strip above it.** The layout's page header — heading, subtitle, back button and `#page-actions` — was hosted by the wrapping `SkCard`, which drew it as a head row of its own directly above the table toolbar, so Users, Roles, Activity Logs and Log Files each showed a heading block and then a separate toolbar underneath. The table now claims that header itself and renders it as the toolbar's head row, above the same divider a titled toolbar already puts between its heading and its search/filter line, so the heading, its page actions and the table's own controls read as one header. A table that carries `DB.table().title()` is unchanged — its own title stands and the hosted header still contributes only the back button and the page actions — and the `main` theme, which draws the page header in place and never hosted it in a card, is untouched. The Activity Logs and Log Files screens dropped their now-redundant `.title()` so their heading comes from the page like every other screen's.
- **Horizontal `SkTabs` now draws its tab strip inside a card of its own.** The strip sat bare on the page background above the panel; it now lives in an `SkCard`, and under aura that card is also the one hosting the layout's page header — so a tabbed screen reads as one block: heading, subtitle, divider, tab row, then the panel. The tabs themselves dropped PrimeVue's default tab chrome for a flat icon + label, muted until active, with the primary color and a 2px bottom bar as the only active indicator. Panels keep their own card exactly as before and the vertical layout is untouched. One class rename to be aware of if you styled it yourself: the horizontal tab icon is now `.sk-tabs__icon`, not `.sk-vtab__icon` (the vertical nav still uses the latter).

- **The aura theme no longer moves the page title into the topbar; `AdminLayout` hands its one page header to the content card's own head instead.** Aura used to hand the title, subtitle and back button to `AdminHeader`, which left the content's first card headerless and forced every page that wanted a title or an action into a theme branch of its own — the `Users`/`Roles` index pages set the datatable's `.title()` and moved their create button into the table toolbar only under aura, and `ApiRoutes/Index` hand-rolled a whole aura-only header card. The `.title()` call was unconditional, so the `main` theme printed the page heading twice. All of it is gone. `AdminLayout` now renders exactly one `AdminPageHeader` — title, subtitle, back button, `#page-actions` — and hands it to the active theme: under `main` it renders in place as the strip above the content, and under aura the first `SkCard` that claims the header draws it in a head row of its own (the datatable card, the form card, an active tab's card). `SkCard` owns that hand-off: eligible cards register as candidates in mount order and the first one still mounted draws the header, so a switched tab or an Inertia visit — where the incoming card mounts before the outgoing one unmounts — passes it straight on instead of dropping it back outside the card. A `SkDatatable` whose toolbar carries a title (`DB.table().title()`) hosts the header in that toolbar instead and opts its wrapping card out, so a table heading never sits under a page heading. A card that already has a title of its own keeps it and folds the header into that same head row — back button and page actions only, no page heading above the card heading. A card with no title (the datatable card, a plain form card) gets the full header in a head row of its own. Transparent wrappers and dialog bodies never register, and chrome cards opt out with `host-page-header="false"` — the vertical-`SkTabs` navigation card, `AvatarUpload` and a FormBuilder section, so the header lands on the real content card and never in a page's sidebar. Kit screens that hand-rolled their own card surface (the settings **Appearance** tab, the API-routes page, the log viewer) now use `SkCard` too, so aura shows the header in the same place on every screen. A page component never branches on the active theme. The Files screen renders no `SkCard` at all — the file manager is its own surface — so `FileManager.vue` claims the header itself and draws it at the top of its sidebar rail, above the **Upload new file** button, instead of leaving aura to fall back to the bare strip above the manager. **Breaking for consumer pages:** the `header-in-card` prop on `AdminLayout` and the `usePageHeader()` composable (`resources/js/composables/usePageHeader.ts`) were removed — drop the prop, and delete any `#title-end` back button that read `pageHeader.active`; the layout draws that button now. `AdminHeader`'s `page-title`, `page-subtitle` and `show-back` props and its `back` emit were removed with them. Candidacy is reactive on both axes: switching the theme on the Appearance screen re-decides it while the cards stay mounted (it used to freeze at setup, drawing the header twice on aura → main), and a card inside an inactive-but-mounted tab panel (horizontal `SkTabs`, or any `panels: 'all'`) stands down so the header follows the visible panel instead of disappearing with the hidden one. `AdminHeader.vue` still accepts the old optional `pageTitle`/`pageSubtitle`/`showBack` props and still emits `back`: `sk:update` refreshes it and `AdminLayout.vue` independently, so an app that customised the layout keeps the old one, which still binds them.
- **Vertical `SkTabs` tab buttons now carry `role="tab"` instead of no explicit role, and the panel content is wrapped in a new `role="tabpanel"` `<div>` inside the card body.** A test selecting a tab button with `getByRole('button')` must switch to `getByRole('tab')`; custom CSS relying on a direct-child selector under the card body may need a look.
- **`TabsBuilder.build()` now throws on a duplicate tab key in development builds, and logs the same message via `console.error` in production instead of staying silent.** Duplicate keys used to silently break slot resolution and URL selection — the second tab rendered the first one's content and could never be reached via `?tab=`.
- **`SkTabs` no longer imports the published `useUrlTab` copy from `@/composables`; it now owns an equivalent active-tab state internally.** This removes a version-skew risk (`sk:publish --tag=composables` could leave an app on an older `useUrlTab` than the shipped component expects), but a project that hand-edited its published `useUrlTab.ts` specifically to change `SkTabs`' behavior will no longer see that edit take effect — `useUrlTab()` itself is unaffected for app code that calls it directly.
- **Cross-page "select all filtered" bulk selection now fails closed on an unsupported filter instead of silently dropping it.** `BulkFilterSnapshot::normalize()` rejects an active `filter[...]` key it can't apply with a 422 (`sk-bulk.unknown_filters`) rather than dropping it from the snapshot, which previously resolved a set wider than what the table showed and let a bulk action reach rows the dropped filter was hiding. Affects the shipped `UserBulkSelectionQuery` and `RoleBulkSelectionQuery`. Only a `null` value or an empty array counts as inactive — the two shapes Spatie's `AllowedFilter` skips; an empty or whitespace-only string is passed through verbatim and applied with the table's own predicate (an exact filter yields the same empty set the table showed, `search`/date bounds ignore it), so a blank value can never widen the bulk set either.
- **`DatatableQueryBuilder::columns()` payload shaping is fail-closed.** A `?columns=` request parameter with no key matching a declared column previously fell back to returning the full row; it now reduces every row to the `alwaysInclude()` keys only, matching the "declared column keys must match the frontend" contract.
- **`TB.tabs().queryParam()` now rejects an empty or whitespace-only name.** It throws in development builds and `console.error`s in production, keeping the name already set (the `tab` default or an earlier call) — an empty name used to be stored as-is and produced a `?=key` URL parameter that nothing could read back, so the tabs silently stopped syncing with the URL.
- **The installer commands now exit non-zero when a mandatory step fails.** `sk:install`, `sk:update`, `sk:upgrade` (and the published `site:install` stub) invoked `migrate`, `db:seed`, `vendor:publish`, `sk:seed-permissions`, `passport:keys`, `key:generate`, `composer dump-autoload`, `npm install` and `npm run build` without ever reading the result: a `migrate` that died on a bad connection still printed `DONE`, the resume checkpoint recorded the step as finished, the stub-hash registry was written, and the command exited `0` — a CI job went green over a half-installed application. Every sub-command result is now checked; a failed **mandatory** step (publish, migrations, seeders, permission seeding, Passport keys, encryption keys) aborts the run, leaves the checkpoint pending so `sk:install --resume` picks up where it stopped, skips the registry write, and ends with one line naming the failed step and the resume command. **Frontend steps stay non-fatal on purpose** — `npm install`, the Wayfinder generation and `npm run build` (plus `composer dump-autoload` and cache clears) only warn, print the command to run by hand, and are listed again in the closing summary, so a machine without Node or composer still installs exactly as it does today. A CI pipeline that currently passes with a silently failing migration will now go red — see [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade). The `site:install` change lives in a stub, so it reaches new installs and `sk:update`-refreshed apps only; an existing consumer copy is untouched.
- **Breaking: the kit's API documentation moved from Scramble's default `/docs/api` to the new api-dock panel at `/api-dock`.** Scramble's own routes are now explicitly disabled, so `/docs/api` and `/docs/api.json` 404 on any app that updates. Anything bookmarking or linking the old URL must move to `/api-dock`, and a consumer who published their own `config/scramble.php` should check it no longer registers a competing docs route. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade). The switch-off is conditional on the replacement actually being there: `Scramble::ignoreDefaultRoutes()` now also requires `Lvntr\ApiDock\ApiDockServiceProvider` to exist, so an install that carries this version's source without having let Composer resolve its new requirement — a path/VCS repository tracking `dev-main`, a `vendor/` restored from an older lock, an install that pinned api-dock away — keeps Scramble's routes instead of ending up with no documentation surface at all (api-dock is not registered in that window either, and the API Routes screen hides its panel button on the same missing route). The normal upgrade path is unaffected: `composer update lvntr/laravel-starter-kit` installs api-dock along with the kit.


- **`@tiptap/extension-task-item` and `@tiptap/extension-task-list` were removed from `stubs/package.json`** — neither is imported anywhere in the kit's own code. If your own code imports either directly, add them back to your app's own `package.json`.
- **`stubs/app/Models/User.php`'s `@property` docblock was corrected to match the model's actual columns and relations.** No runtime behaviour changed.
- **`phpstan-baseline.neon` was triaged and narrowed from 259 to 238 findings**, fixing a handful of genuinely-fixable issues (stale docblocks, unreachable branches) rather than carrying them.
- **CI's real-database jobs (MySQL, MariaDB) now block merges instead of only reporting**, now that they've run green for a release.
- **`release.sh` now requires the GitHub CLI (`gh`) and a green remote CI run on the exact commit being tagged, checked before the local quality gate.** It also now runs `composer analyse` and a full frontend gate (build/typecheck/lint/test). **Release flow changed:** push to `origin main` and wait for CI to go green before running `./release.sh`. This is repo-tooling only — it does not affect `composer update` or `sk:update`.
- **`sk:doctor`'s 22 checks now resolve their name, message and hint strings through `sk-doctor.*` lang keys instead of hardcoding them in PHP.** Every class in `src/Console/Doctor/Checks/` calls `__('sk-doctor.<check>.<case>', [...])` for its user-facing text, so both the admin **System Health** screen and `php artisan sk:doctor` now follow the app's active locale instead of always printing English. English wording is unchanged byte-for-byte, and a consumer app can override any single string by publishing `app/lang/<locale>/sk-doctor.php` and redefining just that key — the namespace-less lookup already used by `sk-bulk.*` and friends means the app copy wins over the package's own. `sk:doctor --only=<selector>` is unaffected by the translation: selectors are now derived from each check's CLASS name (`DatabaseConnectionCheck` → `database-connection`) rather than its displayed name, so they stay identical in every locale, and the three documented selectors that never matched their class name (`filemanager-disk`, `permission-matrix`, `unresolved-routes`) keep working as aliases.
- **The frontend build/lint/test tooling in `stubs/package.json` moved onto its current releases: `@types/node` `^26.5.0`, `concurrently` `^10.0.5`, `eslint-plugin-vue` `^10.11.0`, `typescript-eslint` `^8.70.0` and `unplugin-vue-components` `^32.1.0`.** None of these reaches a shipped bundle — no runtime dependency changed and no component source was touched — but `unplugin-vue-components` crossing a major does affect the consumer's own Vite build, so the whole gate was re-run against it: `vue-tsc --noEmit`, ESLint over both `stubs` and the `@lvntr/components` library, the 290-test Vitest suite, and both Vite builds (client and SSR).

### Fixed


- **Rolling back the `oauth_device_codes.user_id` repair migration no longer rewrites the schema of an install it never touched.** The forward step is conditional — it only converts a legacy bigint `user_id` to uuid, and does nothing on a fresh install, where the create migration already declares `foreignUuid`. Its `down()` was not: it converted the column back to bigint whenever it read as uuid, which after the batch has run is *both* starting points. MySQL accepted that conversion silently; MariaDB 10.7+, where `uuid()` compiles to the native 16-byte type rather than `char(36)`, refuses it outright (`Cannot cast 'uuid' as 'bigint unsigned' in assignment`) and failed the rollback. `down()` is now deliberately a no-op: the values the forward step nulled were invalid identifiers by definition and no type change brings them back, a full-batch rollback drops the table through the create migration anyway, and a single-step rollback leaves the column as uuid — which the forward step then reads as already-repaired and skips.
- **An admin page taller than the viewport scrolls again instead of being cut off at the bottom.** `.admin-content` is the scrollport and lays its children out as a flex column, so every page root is a shrinkable flex item — and a child that clips its own overflow (`.sk-card` is `overflow: hidden`) resolves its automatic minimum size to `0` rather than to its content height. A long form card was therefore squeezed into the leftover viewport height and clipped its own fields, while the scrollport itself had nothing left to scroll: the page looked frozen with the last section and the footer buttons unreachable. The scrollport's children now carry `min-height: min-content`, so a page keeps its natural height and scrolls; a root that deliberately fills the viewport instead (the file manager's `min-h-0 flex-1` shell) is unaffected, and datatable screens, which cap their own body height against the same scrollport, render byte-identically.
- **`sk:doctor` no longer warns about an unverifiable queue worker when Horizon is running.** The `Queue Worker` check treated every async driver alike, so a `redis` queue always reported *"worker liveness cannot be verified automatically"* — including on the many installs that run Laravel Horizon, which publishes exactly that liveness itself. On a `redis` queue the check now reads Horizon's master supervisor record when Horizon is installed: a running master reports OK with the supervisor count, an entirely paused one warns and points at `horizon:continue`, no master at all warns that Horizon is installed but not started, and a status that cannot be read (Redis down, for instance) warns with the underlying error. Horizon stays a non-dependency — its contract is resolved by FQCN string behind an `interface_exists()` guard — so an install without it, or one on `sqs`/`beanstalkd`, keeps the previous generic warning unchanged.
- **`sk:install` now names the cause when the existing `users` table cannot host the kit's schema, instead of dying on a foreign key.** The kit keys `users` on a uuid; a stock Laravel app that ran `php artisan migrate` BEFORE installing the kit has `users` with a `bigIncrements` id — and has stock Laravel's users migration recorded in the ledger under the *same filename the kit publishes* (`0001_01_01_000000_create_users_table.php`), so the kit's uuid version of that file never runs. The mismatch surfaced several migrations later as a bare `SQLSTATE[HY000] ... 3780` naming `file_folders.created_by` and `users.id`, with nothing in it an operator could act on. The migration step now probes the existing `users.id` before offering a strategy: it prints the cause and the remedy, labels the additive option `WILL FAIL`, and stops the step with that reason if it is chosen anyway (or the session cannot prompt). Unlike the row probe that guards `migrate:fresh`, this one **fails open** — an unreadable schema is never treated as a conflict, so it cannot block an install on a question it could not answer. The fix is a full reset: `migrate:fresh`, the `fresh` option when offered, or an empty database, then `sk:install --resume`.
- **`sk:install` is no longer documented as a recovery path for an existing project.** `docs/install.md` and `docs/update.md` described re-running it as an idempotent whole-project repair. It is not: the hash registry only protects a file you deleted rather than one you edited, and a consumer-edited published file is skipped and reported rather than refreshed unless `--force` is passed, in which case it is overwritten outright — neither mode gives the selective, edit-preserving refresh `sk:update` or a scoped `sk:publish --tag=<area>` gives. Both guides now carry the warning, and `UPGRADE.md` records the boundary.
- **`sk:install` refuses to run on an app it did not install.** The command trusted its own hash registry (`storage/starter-kit/hashes.json`, git-ignored) as the only signal that a project was already installed; losing that registry made a live application look brand new. A fail-closed detection pass now runs ahead of the banner — kit schema tables and install-only paths are checked, and if any are present without a matching registry, the command stops before writing anything. `sk:update` and the new `php artisan sk:install --adopt` (rebuilds the registry only, `--dry-run` previews it) are named as the way out; `--force` still proceeds but is no longer treated as a first install.
- **An existing `.env` is never overwritten by `sk:install`, on a first install or a re-run.** A first install used to copy `.env.example` straight over an existing `.env`, destroying `DB_PASSWORD`, `APP_KEY` and anything else already configured. The installer now merges: missing `.env.example` keys are appended and first-install-only keys are seeded only where absent, and no existing value is ever rewritten. `.env` is created from `.env.example` only when it does not already exist.
- **A consumer-modified published file is now skipped by `sk:install`'s re-publish path too, not only `sk:update`'s.** Both commands share the same three-way stub/target/registry-hash comparison; a file that no longer matches the last-recorded hash is treated as a consumer edit, skipped, and reported instead of silently overwritten. `--force` remains the opt-out.
- **`sk:install` no longer overwrites an untracked file on a re-install.** A file the hash registry had no record of at all — because a newer package version started shipping into a path it had never shipped into on this app before — used to be overwritten regardless of `--force`. It is now treated the same as a consumer edit: preserved and reported, unless `--force` is passed. The protection only applies once a registry exists; a genuine first install still publishes every path, tracked or not.
- **`sk:install`'s destructive `migrate:fresh` option now requires a typed confirmation, not a `select()` answer.** The install-time menu offered "drop all tables and run fresh migrations" as an ordinary yes/no choice, one wrong keystroke away from an irreversible reset. Choosing it now prompts for the database name (or the word `fresh`) typed at a `text()` prompt; anything else, including an empty answer, falls back to the additive `migrate` path with nothing dropped. The option is also withheld outright when `APP_ENV` looks production-like, `APP_DEBUG` is off, the session cannot prompt, or any existing table already holds rows.
- **An account disabled while its session is still open is now logged out on the next request.** The login path already refused a non-active account, but could not reach an already-open session. The new `EnsureUserIsActive` middleware checks `status` on every `web`/`api` request and terminates the session when it matches the operator's deny-list (`starter-kit.security.active_status_denied`, default `['inactive', 'banned']`); it is deliberately fail-open on every ambiguous case (no status attribute, non-string value, an unlisted status) and can be disabled outright via `starter-kit.security.enforce_active_status = false`.
- **A setting that cannot be decrypted is no longer silently indistinguishable from an unset one.** `SettingService` caught every `Exception` while decrypting and returned `null`, which `allGrouped()` then cached for an hour — so a wrong key, a corrupted payload or a misconfigured cipher quietly fell back to the env/default value on mail, storage and Turnstile. Only `DecryptException` is handled now (still `null`, but logged without the ciphertext); anything else propagates.
- **`encryption:health` now fails closed when its config is cached and stale.** Under `config:cache`, the command could not tell "cache predates an `.env` edit" from "the value was never sourced from env at all" — so a stale cached chain could report `safe-to-clear` on a key set the app was about to stop using. A cached configuration whose resolved key chain no longer matches `.env`/the process environment now downgrades the verdict to `incomplete` (exit 1) instead, with a `Run php artisan config:clear` instruction, closing a path that could have turned a rotation into permanent data loss. See [encryption.md](https://starter-kit.lvntr.dev/docs/encryption).
- **`sk:install`'s existing-app detection markers were dead in production.** `EXISTING_APP_DIRECTORY_MARKERS` carried `resources/js/Pages/Admin` (uppercase `Pages`), which the stub tree never ships — only case-insensitive local filesystems (macOS) masked the mismatch. `KIT_SCHEMA_TABLES` checked for a `file_manager_folders` table this kit has never created, instead of the actual `file_folders`. Both are corrected, restoring the fail-closed detection pass those markers exist for.
- **An unreachable database at install time no longer reports a successful install.** `sk:install` used to skip the database block (migrations, seeders, permission seeding) on a connection failure with only an on-screen warning, still write the stub-hash registry, clear the resume checkpoint, and exit `0`. The run now ends **incomplete**: the registry is withheld, the checkpoint is preserved so `--resume` continues exactly where it stopped, and the command exits non-zero. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).
- **`sk:install` no longer deletes `package-lock.json` on a re-install or a `--resume`.** The lockfile is the application's pinned dependency graph, and `installFrontend()` removed it unconditionally before `npm install` — so a re-run re-resolved every package onto versions the app had never been tested against, even while the run's own summary reported the file as kept. It is now removed on a **first** install only (where a lock left over from an unrelated `package.json` is only in the way), and the decision moved inside the `Installing npm dependencies` step: a `--resume` run that skips that checkpointed step no longer deletes the lockfile the first run had just written with nothing left to regenerate it.
- **A `--resume` run no longer wipes the `node_modules` it just installed.** Clearing the stale dependency tree sat in front of the `Installing npm dependencies` step and ran only when a tree already existed — so on a first run there was nothing to clear and nothing to checkpoint, while the `npm install` that created the tree WAS checkpointed. An interrupted install followed by `sk:install --resume` therefore deleted the freshly installed `node_modules` and then skipped the install that would have refilled it, leaving `npm run build` to fail on missing dependencies. The clear now happens inside the same step, next to the lockfile decision: skip the step, skip both.
- **`encryption:key`'s "Next steps" output now matches the documented rotation order.** The numbered list opened with `encryption:rekey`, while a cached configuration still resolves the key that was just retired — so an operator following the output re-encrypted every row onto the wrong key, or rewrote nothing at all. When config is cached, `php artisan config:clear` is now listed as its own first step, ahead of the rekey, exactly as [Data Encryption](https://starter-kit.lvntr.dev/docs/encryption) prescribes.
- **`encryption:key`'s `.env` reads now match what the running app actually resolves.** `DATA_ENCRYPTION_KEY`, `APP_KEY` and `DATA_ENCRYPTION_PREVIOUS_KEYS` were read out of `.env` with a hand-rolled regex that returned a `${VAR}`-interpolated assignment (e.g. `${APP_KEY}`) verbatim instead of resolving it, and disagreed with the real dotenv parser on inline comments and some quoting. Reads now go through the same parser the app boots with (`Dotenv::parse()`), so an interpolated reference resolves the same way, and the resolved material — never the literal reference — is what gets prepended to `DATA_ENCRYPTION_PREVIOUS_KEYS`. A `.env` the parser cannot read now aborts the rotation before a key is generated or anything is written, instead of misreading it silently; the parser's own error is withheld from the report because it can quote the malformed line back, which may itself be key material. The rotation also stops when the **process environment** overrides one of those keys — directly, or through a variable that an interpolated value references — because the file would then state one value while the running app resolves another; rewriting `.env` could not close that gap, since the process value keeps winning, so the only safe move is to stop before a key is generated and let the operator resolve the divergence.
- **A `media` table migration rollback no longer leaves the schema and the ledger disagreeing.** `create_media_table` had no `down()`, and Laravel's migrator guards that call with `method_exists` — so `php artisan migrate:rollback` silently skipped the table while still deleting the migration's ledger row. The table survived, its record did not, and the next `migrate` failed on a table that already existed. It now declares a `down()` that refuses rather than destroys: an empty table is dropped, and a rollback attempted while rows remain stops with an error. Dropping a populated `media` would remove the *rows*, not the *files* — Spatie deletes the underlying blobs only through the model's deleting event, which a schema rollback bypasses, so the disk would be left holding orphaned files with nothing left to index them. Delete the media through the application first if you mean to roll it back. The two later migrations in the same chain (`add_folder_id_to_media_table`, `add_soft_deletes_to_media_table`) carry the identical refusal, because a batch rolls back newest-first: without it they would have dropped `folder_id` and `deleted_at` off a populated table before the create migration's guard was ever reached. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).
- **`definitions.lang` is narrowed so the table's composite unique index stops sitting under InnoDB's key-length limit.** `unique(['key', 'value', 'lang'])` over three default 255-character columns sat at 3060 of the 3072-byte limit — a single character of headroom on any one column away from breaking outright. `lang` narrows to 35 — the widest locale value the kit already accepts anywhere (`content_languages.code`), so nothing storable through the kit's own screens is affected — while `key` and `value` keep their published 255, because `lang` alone leaves ~892 bytes of headroom and narrowing them would only block data the current schema accepts; a new migration measures every existing row, soft-deleted ones included, before touching the schema and refuses — leaving the schema unchanged — if a single row would be truncated. Both directions end by asserting the unique index exists, so a table that reaches the migration with the index already missing gets it rebuilt instead of being recorded as migrated without its guarantee. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) for what to clean up if it refuses.
- **`sk:install` no longer deletes conflicting default Laravel files on a re-install or an update.** Removing `package-lock.json`, `vite.config.*` and `resources/js/app.js` unconditionally destroyed project state the installer has no mandate over on any run past the first. Deletion is now first-install-only; a later run reports the conflicting files it found and left alone instead of deleting them.
- **The MySQL/MariaDB CI slice now runs the real migration chain instead of the install test suite.** `tests/Feature/Install` exercised installer logic, not migrations, so a migration that broke on MySQL or MariaDB's stricter DDL could pass CI unnoticed; the MariaDB job also ran through the `mysql` driver, so `uuid` columns never took MariaDB's native path. Both jobs now run `tests/Feature/Migration` against their own driver (`mysql` / `mariadb`).
- **The settings cache is cleared after the outer transaction commits, not during it.** `setValue()` / `setGroup()` called `Cache::forget('settings')` inline, so a write wrapped in an outer transaction (`UpdateAuthSettingsAction`) dropped the snapshot while the rows were still uncommitted — a concurrent reader could miss, re-read the pre-write rows and cache them for another hour. The clear now runs through `DB::afterCommit()`, which still fires immediately when no transaction is open.
- **Logo, favicon and avatar uploads store the new file before dropping the old one.** All three deleted the existing asset first, so a failed `store()` left the setting pointing at a file that no longer existed. A failed upload now leaves the current image in place and returns an error instead.
- **A media object is removed from disk only once its row's deletion has committed.** Spatie's `MediaObserver::deleted()` removes the file inside the transaction that deleted the row, so a rollback restored a row pointing at a file that was already gone. The removal now goes through `DB::afterCommit()`: it is discarded on a rollback, and the worst remaining outcome is an orphaned file, which is recoverable. A non-transactional delete keeps today's timing and still surfaces its failure.
- **Restoring a folder from trash no longer creates a duplicate name.** `CreateFolderAction` rejects a duplicate, but the trash was a way around it at the root level, where MySQL and SQLite treat two NULL `parent_id` values as distinct and the unique index does not fire. The restore now refuses with the same domain error.
- **The FileManager quota calculation works on a bare Spatie `Media` model again.** `computeStorageUsed()` called `withTrashed()` unconditionally; without the SoftDeletes trait that macro does not exist, so every upload validation threw `BadMethodCallException`. It goes through the capability-aware helper the rest of the trait already used.
- **`file-manager:purge-trash` no longer loads the whole trash into memory, and reports failures.** The command read every matching row with `get()` before deleting, takes a cache lock so two schedulers cannot purge the same rows at once, walks the rows with `chunkById` (`--chunk=`, default 500), keeps going when one item fails, and returns a non-zero exit code when anything was left behind. The published schedule entry gained `withoutOverlapping()`.
- **Console output points at documentation URLs that exist in an installed app.** `/docs/**` is `export-ignore`d, so a `prefer-dist` install has no docs directory — yet `sk:install`, `sk:update`, `sk:upgrade` and `sk:doctor` printed local `docs/…` paths mid-migration and during key rotation. They now print a URL pinned to the installed version.
- **`encryption:health` and `encryption:rekey` say which surfaces they can actually vouch for.** Both reported on the kit's own key chain while a consumer-installed encrypter on the Fortify or model-cast path was invisible to them, so a rekey could report success over rows it never re-encrypted. Each surface is now reported with the encrypter that serves it, an encrypter the kit did not build is named as unvouched (`verdict: not-covered`, exit 1), and `encryption:rekey` refuses before reading a row instead of printing a complete rekey. The stale-published-config gap — a `config/starter-kit.php` predating the encryption block, where `DATA_ENCRYPTION_KEY` is inert while health reported "safe to clear" — is reported as its own diagnosis.
- **An encryption key is never written into a file whose permissions could not be restricted.** `encryption:key` chmod'ed its temp file to `0600` without checking the result; on a filesystem that ignores permissions the very next line wrote key material into a world-readable file. The mode is verified while the file is still empty, and the command aborts otherwise.
- **`encryption:key` and `encryption:rekey` cannot run at the same time.** Both read the key chain, decide a new one and write it back, so two concurrent runs could drop a key that was still needed to read existing rows. They now share one cache lock; a `--dry-run` rekey is unaffected.
- **`DELETE` on a FileManager file validates its context like every other route.** The endpoint built the context DTO straight from the request, so a malformed one surfaced as a 500 instead of the documented 422 envelope.

- **The timezone select in the Users create/edit dialog now lists every timezone instead of only the site default.** `UserForm` receives the identifier list as a prop, and the `Admin/Users/Index` page — where both dialogs are opened — never passed it, so the component fell back to its empty default and the select offered a single "site default" option. The list is now supplied by `UserController::index()` and forwarded by both `dialog.open()` calls. The full-page `Users/Create` and `Users/Edit` routes were unaffected.
- **A `FB.datePicker()` value no longer drifts by a day on a form round-trip.** A date-only string (`"2024-03-10"`) from the server was parsed with `new Date(value)`, which JavaScript treats as UTC midnight; formatting it back for submission (`toLocalDateStr`) then reads it in the browser's local timezone, shifting the day in any timezone behind UTC. The date is now parsed component-wise (`new Date(year, month - 1, day)`) as local midnight, matching how it's serialized back.
- **`SkForm` no longer shows two error toasts for the same failed request.** The form raises its own `data_load_error` / `options_load_error` toasts with specific wording, but its internal `useApi()` call also fired the composable's default generic toast for the same failure. `SkForm`'s `useApi()` instance now opts out with `{ toast: false }`; a consumer's own `useApi()` calls are unaffected.
- **`FB.checkboxGroup().optionsUrl(...)` now actually fetches remote options.** The dynamic-options watcher matched select-like fields against a hardcoded type list that omitted `checkbox-group`, so a checkbox-group field configured with `optionsUrl` silently never fetched — it now uses the same `SELECT_TYPES` set as everywhere else.
- **A stale response from a dependent `optionsUrl` field can no longer overwrite a newer one.** Rapidly changing the field that drives a dependent select's URL (typing in a search box, quick re-selects) could let an older request's response land after a newer one, showing outdated options. Each field now tracks a monotonic per-request counter; a response is applied only if it's still the latest request for that field, otherwise it's dropped silently (no options write, no error toast).
- **A form/field marked read-only can no longer be reactivated by a field's own `.props({ disabled: ... })`.** Form-level `disabled` (from `.permission()`) or a field's own computed `disabled` is now a floor rather than a default — `.props({ disabled: false })` can no longer unlock a read-only form, while `.props({ disabled: true })` still disables an otherwise-enabled field.
- **Translatable field defaults on a create form now match the locales the field actually renders.** The empty `{ locale: '' }` seed used to read `availableLocales` from the Inertia page directly, which lists admin-UI locales; the field itself renders DB-backed *content* locales via `TranslatableInput`. When the two lists diverged, the submitted payload could carry keys for locales the field never showed (or miss ones it did). Both now resolve through the same `core/locales` helpers.
- **A dropped file that exceeds `maxFileSize` or the multi-file `fileLimit` is rejected instead of silently added.** `FB.fileUpload()`'s drag-and-drop path now runs through the same validation as the file picker, deduplicates a file already in the keep-list, and revokes its blob object URL when removed instead of leaking it.
- **A `<label for>` on six wrapper-rendered field types (`input-number`, `date-picker`, `select`, `multiselect`, `toggle-switch`, and `password` with `.feedback()`) now targets a focusable element.** These types render their PrimeVue control inside a non-focusable wrapper, so a plain `label[for=key]` pointed at nothing clickable; the inner control now receives `${key}__control` via PrimeVue's `inputId`, and the label's `for` targets that id.
- **Vertical `SkTabs` tab buttons no longer submit an enclosing form.** The sidebar nav `<button>` had no explicit `type`, so browsers defaulted it to `type="submit"` — clicking a tab inside a `<form>` could submit the form instead of just switching tabs. It now sets `type="button"`.
- **A tab's `visible`/`disabled` state changing after mount now correctly drives the URL-synced active tab.** `useUrlTab()` used to close over a fixed snapshot of the tab list taken at mount, so a tab that became visible later couldn't be reached via `?tab=`, and an active tab that became hidden or disabled left the UI pointed at a tab no longer in the list. The selectable list is now a reactive array kept in sync with the live `visible`/`disabled` state, so a newly visible tab is immediately selectable and a hidden or disabled active tab falls back to the first selectable tab.
- **A disabled tab can no longer be activated from `?tab=`, and a disabled first tab is no longer the param-less default.** `useUrlTab()` now resolves both the URL parameter and the "no parameter" fallback against the selectable (non-disabled) tab list instead of the full list.
- **Re-selecting the already-active tab no longer fires an Inertia visit, and `#hash` now survives a tab switch.** Clicking the active tab again (or re-assigning it to its current value) used to still call `router.visit()`; it is now a no-op. Switching tabs also preserves any `#hash` present on the current URL instead of dropping it.
- **Cross-page bulk selection on the Users table now honours the same `created_at_from`/`created_at_to` date-range bounds the datatable renders with.** `UserBulkSelectionQuery` now applies dates through `DatatableQueryBuilder::applyCalendarDateRange()`, the same helper the table's own query uses, so the bulk-resolved set can no longer drift from the visible set across timezone/DST boundaries.
- **A selected row's id is sent to a bulk action exactly as the table displays it, without numeric coercion.** `useDatatableSelection()`'s `executeBulkAction()` no longer converts a numeric-looking id before posting; UUID/ULID and integer primary keys alike round-trip unchanged.
- **The ID column on the API Token and API Client tables is sortable again.** `ApiTokenController::dtApi()` and `ApiClientController::dtApi()` now allow-list `id` alongside `name` and `created_at`, so clicking the ID header no longer returns Spatie `QueryBuilder`'s `InvalidSortQuery` 400.
- **`BulkActionRequest` no longer rejects a cross-page bulk request that carries no `ids`.** The published request required `ids` (`min:1`) even when `select_all_filtered` was `true`, contradicting the documented payload and 422-ing a host that calls `useDatatableSelection().executeBulkAction()` in "all" mode with nothing selected on the current page (the shipped Users/Roles pages never reach that state — "select all filtered" is only offered from the bulk bar). `ids` is now `Rule::requiredIf(! select_all_filtered)`; ids that are sent are still shape-checked (`array`, `max:500`, opaque strings). An unmodified copy is refreshed by `sk:update`.
- **The shipped `lvntr-kit-frontend` and `lvntr-starter-kit` skills now name the real auto-label key.** Both told the agent an omitted `.label()` resolves from `sk-attribute.attributes.{key}` in a `lang/{locale}/sk-attribute.php` file that does not exist; `FB` fields and datatable column/filter labels resolve from `validation.attributes.{key}` (`lang/{locale}/validation.php`). The FormBuilder guide also now states that in external `v-model` mode `initialData()`, a field's `.default()` and `dataUrl` data seed the internal form only and never populate the bound object.
- **`SkTabs` icons are hidden from assistive technology and a checked tab announces its state.** Tab icons in both layouts carry `aria-hidden="true"` (the label already names the tab), and the `.checked()` check mark — state, not decoration — is paired with visually hidden text (`sk-common.completed`, a new key in the EN/TR bundles) so a screen reader hears it instead of skipping it.
- **Cross-page "select all filtered" now applies a literal `true` / `false` filter value exactly as the table does.** Spatie's `QueryBuilderRequest` turns those two strings into booleans before a datatable filter ever runs, but `BulkFilterSnapshot::normalize()` passed them through as text — so a search for `true` resolved a bulk set matching the word "true" while the table had matched "1", and could reach rows the table never showed. The snapshot now performs the same coercion, and the word-search predicate moved into `DatatableQueryBuilder::applySearchWords()`, the single helper the table's `search` filter and the shipped `UserBulkSelectionQuery` / `RoleBulkSelectionQuery` all use, so the two paths can no longer drift on how a value is split, escaped or coerced.
- **A comma in the datatable search box no longer breaks the request.** Spatie's query builder explodes every `filter[...]` value on `,` before the table's search callback sees it, so a search such as `Acar, Levent` reached the callback as an array and the request failed with a `TypeError` (HTTP 500). `DatatableQueryBuilder::applySearchWords()` now re-joins the exploded value with the same delimiter and searches the text as typed; the cross-page bulk selection already applied the raw text, so both sides resolve the same set.
- **The desktop search box's clear (×) control in `SkDatatable` is now a real `<button>`, reachable and operable from the keyboard.** It was a click-only `<i>` icon with no accessible name; it now carries the new `sk-datatable.clear_search` label, which the mobile search popover's clear button uses as well (it previously announced "Close"). A test or stylesheet that targeted the control as an `<i>` element must target the button instead — the `sk-dt-search__clear` class is unchanged.
- **`TranslatableInput` labels are now associated with their input.** The label rendered beside the locale switcher (and in single-locale mode) carries `for` = the field key and the active input carries the matching `id`, so clicking the label focuses the field — the same markup the regular form fields already use. When a single locale renders, a required field also marks that input `aria-required` and pairs the decorative asterisk (now `aria-hidden`) with an sr-only "required" text; with several locales the asterisk stays purely visual, because `HasTranslatableRules::translatableRules()` requires the default locale alone and makes every other locale `nullable` — announcing each locale tab as required would be wrong. A `translatable-editor` is named through `aria-labelledby` instead of `for`: its editable node is a contenteditable `<div>`, which `label[for]` cannot target.
- **`EditorInput`'s `id` and ARIA attributes now sit on the node the user actually types in.** The `id` was set on `<EditorContent>`'s wrapper `<div>` while Tiptap renders the contenteditable node inside it, so a `label[for]` pointed at a non-labelable wrapper and assistive technology read no name, role or required state for the editor at all. The editable node now carries the `id`, `role="textbox"`, `aria-multiline="true"` and the two new `ariaLabelledby` / `ariaRequired` props. A stylesheet or test selecting the editor by `#<field key>` now matches the inner `.sk-rte__content` node instead of the `.sk-rte__body` wrapper.


- **`encryption:key` no longer changes what an existing `DATA_ENCRYPTION_PREVIOUS_KEYS` entry means.** The command normalised only the key it was retiring; entries already in the list were copied through verbatim. The list is read through phpdotenv (quotes stripped, `${VAR}` resolved) and written back unquoted, so an entry holding `#`, `$` or whitespace came back as a different key on the next boot — `#` opens a comment and truncates it. Every entry is now made env-safe (re-emitted as `base64:`, decoding to the identical bytes) when its raw form cannot survive an `.env` line.
- **`encryption:key` now verifies its temporary `.env` was written in full, and flushed to disk, before renaming it into place.** A full disk makes `Filesystem::put()` return a short byte count instead of throwing, so a truncated body could replace a complete `.env` — on the first of the command's two writes, that body holds the only copy of the key being retired. A failed reopen, `fflush()` or `fsync()` aborts too: for this file durability is the safety property, and an unflushed write turns the two-write ordering back into a call order.
- **`encryption:key` now carries the `.env` owner and group onto the file it writes, and refuses to replace the file when it cannot.** `sudo php artisan encryption:key` over a `www-data:www-data` `.env` used to leave a root-owned file the web user could not read. Restoring ownership is attempted best-effort (only root can hand a file over) but the result is verified and a mismatch aborts — warning and continuing shipped an unreadable `.env` while reporting success. The mode restore is verified by the same rule: wider leaks the key, narrower locks out the service, both abort. Nothing is replaced on any of these paths.
- **The kit's file-writing commands now refuse to run against the kit's own package checkout.** `sk:install`, `sk:update`, `sk:upgrade`, `sk:publish`, `sk:eject`, `make:sk-domain`, `remove:sk-domain`, `env:sync` and `encryption:key` are covered. The package ships `vendor/bin/testbench`, so `vendor/bin/testbench sk:install` inside a clone boots a real Laravel application whose base path is the Testbench skeleton living *inside* the checkout, with a `vendor/` symlinked straight back to it. None of the already-installed markers fire on that bare skeleton, so the run was classified as a pristine first install and took the force-overwrite path — publishing stubs, merging `package.json`, rewriting `.env`, generating domains and running migrations over the package sources. Each command now stops before the first byte is written: it refuses when the **write target** is the package root or sits inside it **and** that directory does not require the kit through `vendor/lvntr/laravel-starter-kit`. A consumer application is never inside the checkout, and a scratch app nested under it that genuinely required the kit (a `type: path` repository, symlink included) still installs normally. The guard is about writing and only writing: `--dry-run` (`sk:install`, `sk:update`, `sk:eject`), `encryption:key --show`, and a `--destination=` pointing outside the checkout (`sk:publish`, `sk:eject`) all pass — a destination that does not exist yet is classified by its nearest existing ancestor, so naming an uncreated directory inside the checkout is not a bypass. `--force` does not bypass it either: that flag means "overwrite the files I named", never "install the kit into the kit". The package's own Pest suite is exempt, since it drives these commands against the skeleton on purpose.
- **`sk:doctor`'s FileManager disk check now follows the disk FileManager actually writes to.** It previously read a `file-manager.disk` config key that `config/file-manager.php` has never defined, so the fallback (`filesystems.default`) was all it ever checked — an installation that pointed Storage at a disk other than the app's default (`media-library.disk_name`, set by `SettingsServiceProvider`) had the check silently validate a disk FileManager doesn't use. It now reads `media-library.disk_name` first and falls back to `filesystems.default` only when that key is unset. A disk name that isn't defined under `filesystems.disks` at all is now a FAIL (previously it reported the disk as "configured" and OK); a local/public disk whose root directory exists but is not writable by the web server user is now a WARN instead of being reported OK.
- **The new `files.preview` route now answers HTTP Range requests.** `FilesystemAdapter::response()` returns a `StreamedResponse`, which ignores `Range` entirely and replies `200` with the whole body — so once `FileItemDTO` started resolving to this route, the inline `<video>`/`<audio>` player in `FilePreviewModal` could no longer seek, and Safari refused to start playback at all. On a `local`-driver disk both serving actions now return a `BinaryFileResponse`, which does `Accept-Ranges`/`Content-Range`/`206` in `prepare()`; remote drivers, which have no real filesystem path, keep the streamed response.
- **Images inserted through the editor are published again.** `EditorInput.vue` writes the uploaded (or picked) file's URL straight into the persisted document as `<img src>`, and that content is rendered to visitors who never authenticate — so pointing it at the session-gated preview route broke every published image. `FileItemDTO` now carries a second field, `public_url`: `Media::getUrl()` when the backing disk is declared `'visibility' => 'public'`, and `null` otherwise. The editor embeds `public_url` and falls back to the gated `url` only when there is none. This adds no exposure — a public disk serves those bytes to anyone with the path regardless of what the API returns — and the file browser itself still uses the gated `url` throughout. On a private disk `public_url` is `null` by design: there is no link to publish.
- **`sk:install` no longer silently republishes over an already-installed app.** A leftover `install-progress.json` checkpoint — written by any prior run that ended incomplete, e.g. an unreachable database — disabled both the existing-app-markers guard and the confirmation prompt on every later plain re-run, regardless of whether `--resume` was passed; `stepAlreadyCompleted()` already required an explicit `--resume` to actually skip a step, so nothing was really being resumed, just unguarded. A new `confirmReinstall()` gate now asks before republishing whenever the hash registry (`storage/starter-kit/hashes.json`) already exists and the run isn't an actual `--resume`; it fails closed under `--no-interaction` unless `--force` is passed, and a stale checkpoint alone can no longer suppress either guard.
- **A custom permission with two dots in its name (e.g. `system.health.view`) is no longer split on the wrong dot.** Both `GroupedPermissionsQuery` (the Roles form's permission matrix) and `_01_RolePermissionSeeder` split a permission name on the *first* dot to separate resource from ability, so `system.health.view` became resource `system` / ability `health.view` instead of resource `system.health` / ability `view` — the matrix showed a nonsense ability label under the wrong resource. Both now split on the *last* dot instead. The `developer` and `api` permission groups and the `files`, `api-clients`, `api-tokens`, `dashboard`, `system.health` resource labels and the `view` ability label — previously falling back to their raw permission-name segments in the UI — are now in `permission-resources.php` and the EN/TR `sk-role` lang files.
- **The Roles form no longer hides a validation error on an inactive tab.** Vertical `SkTabs` mounts only the active panel; the Basic Info tab (`name`/`display_name`/`color`) is not the default active one, so a required-field error there after a failed submit rendered nowhere and the operator saw no feedback at all. The form now switches to the tab holding the errored field.
- **A fresh install no longer sends a successful login to a 404.** `FortifyServiceProvider` never overrode Fortify's own post-login redirect target, `config('fortify.home')` (`/home`) — a route this kit never registers, since the real landing page is `dashboard.index` (`/dashboard`). The published provider now sets `config(['fortify.home' => '/dashboard'])` in `boot()`; an existing install that customised its own copy is unaffected until it re-publishes.

## 2026-08-25 — v13.6.16

### Fixed

- **A datatable no longer renders empty because a neighbouring table's sort was left in the page URL.** `sort` is a page-global query parameter, so on a page hosting several tables (tabs, side-by-side panels) the table that mounted second read the first one's `sort` out of the URL and asked its own endpoint for a column that endpoint never allowed — `Spatie\QueryBuilder` answers with HTTP 400 (`InvalidSortQuery`), so the table came up blank. A bookmarked link failed the same way once a column had been renamed or dropped. `SkDatatable` now validates a restored sort key against its own columns before using it — the id column included, since it is sortable without appearing in `columns`, and this route's persisted column order too, so a column only the server publishes (a hidden `updated_at`, say) still restores its sort once the user has enabled it. A URL carrying a foreign sort is treated as another table's URL and is ignored **whole** — `page`, `per_page` and the filters with it, because reading half of it would only open this table on the neighbour's page number. A stale key coming back from the per-route session blob is dropped the same way, while a sort the table does own is still restored from both sources.

## 2026-08-24 — v13.6.15

### Changed

- **The default panel no longer ships dead controls.** A fresh install's header carried four user-menu entries with no `command` behind them (account settings, notification preferences, change password, help) plus notification and message popovers filled with invented orders, payments and contacts — hardcoded Turkish strings in an otherwise bilingual kit, with a permanently lit badge and a "mark all read" action that did nothing. All of them are gone; the profile link, the language submenu, logout, the appearance popover and the system-admin developer popover stay. The Dashboard's `Export`, `New Report`, `View All` and `View Report` buttons, which were equally inert, were removed too, and the demo dashboard now opens with a translated banner (`sk-common.demo_banner`) stating that its metrics are sample content. The charts, KPI cards and tables are unchanged, so the screen still shows what the kit's components can do — it just no longer presents fabricated business data as if it were the consumer's own. Translation keys for the removed menu entries were left in the language files.
- **The frontend lint gate is enforced instead of merely running.** `npm run lint` exited 0 while reporting 2,708 warnings across 33 files (2,473 `vue/html-indent`, 231 `vue/max-attributes-per-line`, four other template-formatting findings), so CI's lint step could never fail and a genuinely new warning was invisible in the noise. The whole baseline was mechanically fixed with `eslint --fix` — formatting only, no behavior touched. The gate is enforced through a new `lint:ci` script (`--max-warnings=0`) that the kit's own CI runs; the consumer-facing `npm run lint` still reports warnings without failing, so warnings in an installed app's own code do not break its lint step or pipeline.
- **Inertia pages are resolved lazily, so the first visit no longer downloads the whole panel.** Both page globs in `resources/js/app.ts` were eager, which put all 54 Vue pages — the file manager, the Tiptap editor, every settings screen — into the initial bundle even for a visitor sitting on the login form; a catch-all `vendor` chunk in `vite.config.ts` then pinned those feature dependencies to that same payload. The globs are now lazy and hoisted to module scope, `resolve` returns the matched loader's promise (Inertia v3 awaits an async resolver on the client and in SSR alike, so the previous "SSR needs a sync resolver" constraint no longer holds), and the catch-all chunk is gone so single-page dependencies fall behind their own dynamic-import boundary. The measured initial payload drops from 652.2 kB to 390.4 kB gzip (−40%), split across 121 chunks with 54 dynamic imports off the entry. App-over-vendor page precedence, the `Page not found` error and the language globs (which stay eager) are unchanged. `scripts/ci/check-bundle-budget.mjs` now gzips the entry's static import closure and fails CI above 500 kB, so a regression is caught rather than shipped.
- **Two stale leftovers are gone from the shipped scaffold.** `app.blade.php`'s `<title>` fallback read `Starter Kit 12` — a version number that went stale at every release, and one that only ever surfaced when `app.name` was unset; the fallback is now just `Starter Kit` and carries no version. The Files page separately imported `MyShareLinksDrawer` and rendered it under `v-if="false"`, so ~190 lines of a component still waiting on its backend endpoint were pulled into the Files page's own chunk and downloaded by anyone opening that page, without ever being reachable. That import and its dead state (`sessionLinks`, `drawerVisible`, `drawerMediaId`, `onShareRevoked`) are removed, so the Files page no longer carries it. The component file stays where it is — the recursive vendor page glob still sees it and a build still emits a (now never-fetched) chunk for it — and a comment records what to re-wire once `GET /file-manager/share?media_id=X` exists.

### Fixed

- **Install docs no longer steer new projects onto an ancient release.** The documented flow required the package with `:^13.0`, a range wide enough to include `v13.0.1` — the last release that still accepted `spatie/laravel-activitylog:^4.9`. Because `laravel/laravel` itself only requires PHP 8.3 while this kit (and `activitylog:^5.0`) requires PHP 8.4, `composer create-project` succeeds on PHP 8.3 and Composer then resolves quietly down to `v13.0.1` instead of reporting the platform mismatch; `composer update` afterwards correctly answers "nothing to update". README and the install guides now require `:^13.6`, so Composer fails with the real reason, and they state the PHP 8.4 floor explicitly alongside `composer why-not lvntr/laravel-starter-kit 13.6.14` for diagnosing an unexpected resolved version. The Turkish README and the `sk:upgrade` remediation hint were missed by that pass and still printed `:^13.0`; both now match, and the Turkish README carries the same PHP 8.4 / Node 20.19+ warning as the English one.
- **The production CSP no longer blocks previews served from cloud storage.** `img-src` allowed only `'self' data: blob:` while the kit supports `local`, `s3` and DigitalOcean Spaces disks and the FileManager hands the browser signed URLs on the bucket's own origin — so on a remote disk every preview, and every download the frontend fetches, was blocked by the policy the kit itself sets. `SecurityHeaders` now derives the origins of the media-library disk and the public disk (a disk `url` such as a CDN base, an s3 `endpoint` plus its `*.host` bucket-subdomain form, or the region/bucket pair for plain AWS) and appends them to `img-src`, the new `media-src`, and `connect-src`. Additional origins — a remote image embedded in the welcome message, for instance — go in `starter-kit.security.csp_extra_origins`, which accepts `http(s)` origins only. A response that already carries a CSP is still left untouched, and `local` still gets no policy at all.
- **`sk:doctor` log checks report the effective setting on a config-cached app.** `LogChannelCheck` and `LogStackCheck` read `env()` directly, and once `config:cache` has run `.env` is not loaded, so both reported their defaults rather than what the application actually uses — on the exact deployments where a doctor run matters most. They now read `logging.default` and `logging.channels.stack.channels`. `LogStackCheck` additionally judges only the channel that is actually active: it read `logging.channels.stack` unconditionally, so an app on `LOG_CHANNEL=daily` was warned about a stack its log records never reach, while `LOG_CHANNEL=single` — genuinely unrotated — passed as OK. The check now resolves `logging.default`, expands it into its member channels when it is a stack, and warns when any resolved channel uses the `single` driver, pointing at whichever knob actually reaches the offending channel: `LOG_STACK` only when the active channel is the framework's own `stack`, `logging.channels.<name>.channels` for a differently named stack, and `LOG_CHANNEL` otherwise. That expansion mirrors `LogManager::createStackDriver()` rather than approximating it: a `channels` value written as a string (`LOG_STACK=single,daily`) is exploded on commas instead of being read as one channel name, and members are resolved recursively, so a `single` nested one stack deeper is no longer reported as rotated. A configuration cycle terminates on the resolution path instead of recursing.
- **A cached config no longer silently disables Inertia SSR.** When the consumer has not published `config/inertia.php`, the service provider set `inertia.ssr.enabled` from `env('INERTIA_SSR_ENABLED')` on every boot. `config:cache` captures that override correctly while `.env` is still loaded, but the same code then re-ran on each cached request with `env()` returning null and stomped the cached `true` back to `false`. The override is now skipped when the configuration is cached. Note for existing installs: an app that set `INERTIA_SSR_ENABLED=true` and ran `config:cache` was in fact still rendering client-side, and SSR genuinely engages after this fix — make sure `php artisan inertia:start-ssr` is running. If it is not, Inertia degrades to client-side rendering rather than erroring (`HttpGateway::dispatch()` returns `null` when the bundle is missing or the connection fails, unless `inertia.ssr.throw_on_error` is enabled).
- **An HTTPS asset URL is no longer rewritten to a protocol-relative one.** The mixed-content guard in `SettingsController` and `SettingsDefaultsQuery` stripped the scheme from both `http://` and `https://` public-disk URLs, so an `https://` asset opened over an HTTP page downgraded to HTTP — the opposite of what the "never a downgrade" comment claimed. Only `http://` URLs are rewritten now; an `https://` URL is never mixed content and is passed through as-is.

### Security

- **Twenty-five of the kit's own routes are no longer ungated by omission.** `CheckResourcePermission` derives a permission from a route's name, and a route with no name, fewer than two name segments, or an action segment outside the middleware's ability map resolves to nothing — previously that meant the request passed through in total silence. Twenty-five routes the package itself registers fell into that gap: the five `settings.contentLanguages.*` endpoints, fifteen `settings.update.*` / `settings.upload.*` / `settings.delete.*` writes, `settings.testMail`, `roles.syncPermissions`, and the `roles.bulk` / `users.bulk` endpoints. They are now pinned to a permission by a route-name contract that lives inside the package, so an existing installation gets the fix from `composer update` alone — the route files `sk:install` copied into your app are untouched. Every mapping was chosen to be behavior-neutral: the settings routes already enforced the same permission through an explicit `check.permission:` argument, and `roles.syncPermissions` is additionally restricted to `system_admin` by its own controller. `roles.bulk` and `users.bulk` are declared exempt rather than mapped: the ability they require depends on the action named in the request body, and `BulkActionDispatcher` already authorizes every item against the handler's own ability, so any static route-level mapping could only over-deny (`.delete`, `.update` and `.read` each break a different legitimate role). `system-health.run` is likewise exempted by name, because its controller already calls `Gate::authorize('system.health.view')` and its route group is restricted to `system_admin`. A route that carries its own explicit `check.permission:<permission>` argument is also no longer double-judged by the parameterless group pass.

### Added

- **`sk:doctor` gained an `unresolved-routes` check.** It lists every route in the application, including consumer-added ones, whose permission cannot be derived by `CheckResourcePermission` — the same routes that currently pass on a logged warning rather than a resolved permission. Run `php artisan sk:doctor --only=unresolved-routes` to see them; each one is fixed by a `<resource>.<action>` route name, an explicit `check.permission:<permission>` argument, or a listing under the new `starter-kit.permissions.unrestricted_routes` config key.
- **Two new `starter-kit.permissions` config keys.** `allow_unresolved` (env `STARTER_KIT_ALLOW_UNRESOLVED_ROUTES`, default `true`) controls whether a route whose permission cannot be resolved at all is allowed through with a logged warning or denied; unlike the existing `allow_unmapped`, it keeps applying in production once flipped, because an unresolved route is a structural route/ability-map mismatch rather than a per-host data gap. `unrestricted_routes` lists `Str::is` route-name patterns that are deliberately permission-free and are exempt from both the check and the doctor warning.

- **A new project installs fail-closed on unresolved routes; an existing one is untouched.** `sk:install` writes `STARTER_KIT_ALLOW_UNRESOLVED_ROUTES=false` into the `.env` it creates. A fresh app has no legacy route to grandfather in, so it starts strict and its first ungated route surfaces during development rather than in production. Nothing carries that value into an app that already exists: `ensureEnvFile()` copies `.env.example` wholesale **only on a first install**, and the re-install path now skips a small `FIRST_INSTALL_ONLY_ENV_KEYS` list, so re-running `sk:install` on an installed app does not add the key either. `sk:update` and `sk:upgrade` never touch `.env` at all. **There is no release in which an existing installation starts denying on its own** — the `allow_unresolved` default stays `true` for anything that does not set the key, and an existing app opts in by writing the line itself once `sk:doctor --only=unresolved-routes` is clean. See the [upgrade guide](https://starter-kit.lvntr.dev/docs/upgrade#unresolved-route-fail-closed-is-opt-in-for-an-existing-install) for the ordered remediation path.

## 2026-08-15 — v13.6.14

### Security

- **Activity logs no longer retain credentials.** Fillable and unguarded model logging excludes `password`, `remember_token`, `two_factor_secret`, `two_factor_recovery_codes`, and attributes ending in `*_token` or `*_secret`; a password-only update now creates no activity row. The Activity Log UI also masks these keys in legacy rows. The new irreversible data migration and the idempotent `sk:redact-activity-secrets` command recursively remove them from both `attribute_changes` and `properties`; back up the database before migrating and inspect any undecodable JSON rows reported by the command. Models may extend the deny list through `sensitiveLogAttributes()`. The data migration ships inside the package (`database/migrations/`), so `composer update` plus `php artisan migrate` delivers it without `sk:update`, and it scans every row so a differently-cased key is not skipped on a case-sensitive JSON collation. `sk:doctor` gained an `activity-log-secrets` check that FAILs while credential-bearing rows remain; it is a bounded read-only probe over the first 500 rows by primary key, identical in cost on every driver, and it decides in PHP rather than through a SQL key-name filter, so a `Password`-cased key cannot be skipped by the column's collation. Its messages state what was measured — a finding over a larger table is reported as a floor ("at least N") and a clean bounded result names its window rather than clearing the table; `sk:redact-activity-secrets --dry-run --all` remains the exhaustive count (`--all` is what turns off the SQL key-name prefilter).
- **Authentication settings now fail closed before Fortify registers its routes.** Registration, password reset, and forgot-password requests return 403 when their settings are disabled. A two-factor challenge is claimed with an atomic add-if-absent cache entry, so exactly one of two concurrent redemptions can mint a token — `Cache::pull()` reads as a claim but is a separate get and forget, which rate limiting narrows without serializing. Recovery-code use is additionally protected by a database row lock.
- **User-controlled datatable labels are escaped before reaching `v-html`.** Role display names, activity-log causers, and API-client grant types can no longer inject markup. Frontend dependency locks were also refreshed with non-breaking updates including axios 1.19.0, Vite 7.3.6, esbuild 0.28.2, form-data 4.0.6, shell-quote 1.9.0, and undici 7.29.0; both production and full `npm audit` now report 0 vulnerabilities, and frontend CI enforces `npm audit --audit-level=high --omit=dev`.

### Added

- **`sk:doctor` gained a `permission-matrix` check.** `config/permission-resources.php` is user-owned and `sk:update` never writes to it, so resources and abilities the package adds in a later release never reach an existing installation — the first sign of which is usually a 403 on a screen that used to work. The check diffs the shipped matrix against the one the application has loaded and reports what is missing (`files.update`, `files.delete`, and so on), then points at `sk:seed-permissions`. It is one-directional: resources the consumer added themselves are never reported. It compares abilities by backing value, so an entry written as a `PermissionEnum` case counts the same as the string, and it expands a `null` (all abilities) declaration on the package side from the abilities the package itself ships — never from the consumer's extensible `PermissionEnum` — while treating one on the application side as covering everything.

### Fixed

- **`sk:update` no longer overwrites a consumer-extended `PermissionEnum`.** `app/Enums/PermissionEnum.php` is package-owned and refreshed on every update, but it is also a backed enum with public `for()` / `allFor()` helpers, so adding a project ability (`case Approve = 'approve';`) is the obvious thing to do — and until now that case was copied over whenever the file merely differed from the stub, with no registry check, no backup, and nothing in the summary. The copy is now guarded by the same install-time hash every other consumer-owned file already uses: a provably untouched file is refreshed, an edited one is preserved and reported separately with its merge instructions, an untracked one goes to the existing interactive prompt instead of being assumed unmodified, and `--force` still overwrites.
- **FileManager requests now work on sub-directory installations.** `withBasePath()` is idempotent and `useApi.request()` applies it centrally, preventing missing or doubled base paths.
- **Dashboard and `SkDatatable` no longer access browser-only globals during SSR.**

### Breaking

- **FileManager context authorization now uses `read`, `create`, `update`, and `delete` instead of collapsing mutations into `write`.** The built-in `global` context maps these one-to-one to `files.read`, `files.create`, `files.update`, and `files.delete`; unknown abilities fail closed. A role that previously held only `files.create` loses delete and empty-trash access, while a role that held only `files.update` loses read access. Grant the specific `files.*` abilities required by each role, then run `php artisan sk:seed-permissions`. Consumer context closures must handle the four new names and will never receive `write`; see the [upgrade guide](https://starter-kit.lvntr.dev/docs/upgrade). `authorizeWrite()` remains as a deprecated alias to `authorizeUpdate()` for direct callers.
- **Disabled two-factor authentication now removes Fortify's 2FA routes.** They return 404 instead of remaining registered. The 2FA management endpoints also now carry `password.confirm` because `fortify-options.two-factor-authentication` is set before route registration; direct API consumers must complete that confirmation round-trip.

## 2026-08-15 — v13.6.13

### Changed

- **The kit is now MIT licensed** (previously PolyForm Noncommercial 1.0.0). Commercial use is allowed without restriction — you can ship the kit inside closed-source and paid products, as long as the copyright and permission notice stay in place.
- **Behavior change: API Resource dates are now ISO-8601 values with an offset, not preformatted display strings.** This gives the frontend one parseable instant to format consistently in the user's resolved timezone. `format_date()` itself is unchanged and stays compatible for existing Blade, mail, export, and other display callers.
- **Behavior change: storage and display timezones are separate.** Keep `APP_TIMEZONE=UTC`; `display_timezone` now reads the new `APP_DISPLAY_TIMEZONE` variable instead of `APP_TIMEZONE`. Existing installs must add the variable and run `php artisan sk:upgrade` so its safe, repeatable config rewrite updates `config/app.php`. `sk:doctor --only=timezone-storage` reports a failure if storage is not UTC.
- **MySQL/MariaDB connection sessions are now pinned to UTC.** `sk:install` and `sk:upgrade` add literal `'timezone' => '+00:00'` entries to existing `mysql`/`mariadb` arrays in `config/database.php` without overwriting consumer values or touching other drivers. Existing installations may already carry offset application-written `TIMESTAMP` data; it stays offset until the one-time conversion in [Timezones](https://starter-kit.lvntr.dev/docs/timezone) is completed. `DEFAULT CURRENT_TIMESTAMP` columns move in the opposite direction and must be excluded. `sk:upgrade` warns and asks before changing a non-UTC session with data (unattended runs without `--force` skip), but never converts rows. `sk:doctor --only=timezone-storage` now detects a non-UTC MySQL/MariaDB session, including `SYSTEM`, and warns rather than passing when the session cannot be read.
- **Users can choose their own display timezone, and datatable date filters now respect it.** A blank user preference means “follow the General site setting” and is different from explicitly choosing UTC. The shared user → site → app → UTC fallback applies across backend and frontend formatting; calendar-date filters use DST-correct, half-open UTC ranges without wrapping the indexed column.

## 2026-07-25 — v13.6.12

### Added

- **The kit's AI skills now work with Codex as well as Claude Code.** `sk:install` publishes the three skills to `.claude/skills/` and mirrors them to `.codex/skills/`, which the OpenAI Codex CLI reads natively. Edit the `.claude` copies to customize — the `.codex` mirror is regenerated on every `sk:install`/`sk:update` and never touches your own skills in that directory. `sk:install --without-ai-skill` skips both trees; `sk:update --without-ai-skill` skips regenerating the mirror for one run.

### Changed

- **The shipped AI skills were brought up to date with the current kit** (they still described the pre-v13.6.0 layout): vendor-first architecture, `sk:eject` and the install-time User/Role eject, `sk:doctor`, the full `sk:publish` tag list, `make:sk-domain --with=` extras, the real `sk:update` overwrite rules, the current composables and FormBuilder field types, SkForm's safety guards, and the theme system. Skill bodies are now in English (Turkish trigger keywords retained) so one skill set serves both assistants.

## 2026-07-22 — v13.6.11

### Fixed

- **Uploading a file no longer shows it twice, and the "unsaved changes" warning finally clears on forms with file fields.** After saving a form with an upload field, the file you had just picked stayed in the form alongside the copy that had already been stored — so the same image showed up as two entries. The same leftover file also kept the form permanently marked as changed, which meant the unsaved-changes banner and the "are you sure you want to leave?" prompt never went away, no matter how many times you saved. Saving now refreshes the stored file list from the server and clears the picker, so you see one copy and the form goes clean — and the file you just uploaded is no longer at risk of disappearing when you save the form a second time.

## 2026-07-21 — v13.6.10

### Fixed

- **The "unsaved changes" warning no longer sticks around after you save.** On forms that submit themselves, saving worked but the form still considered itself dirty — so the unsaved-changes banner stayed visible and closing or leaving the page kept asking you to confirm, even though everything had already been saved. Saving now marks the form clean straight away. If you keep typing while the save is still running, those newer edits weren't part of the save — the form stays marked as unsaved for them, so nothing is silently lost. Create forms that clear themselves after saving still clear as before.

## 2026-07-08 — v13.6.9

### Security

- **Unmapped permissions are now denied on staging/demo, not just production.** The `CheckResourcePermission` middleware used to let a request through whenever the required permission was missing from the database on any non-production environment — so a public staging or demo host could silently expose an endpoint whose permission row was forgotten. It now denies everywhere except `local` (local still warns and allows for dev convenience). If you deliberately want the old behavior on non-production hosts, set `STARTER_KIT_ALLOW_UNMAPPED_PERMISSIONS=true`. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).
- **Permission lookups are now Octane-safe** — the seeded-permission list is cached for a short time (60s) instead of for the whole worker lifetime, and both `php artisan sk:seed-permissions` and the Roles screen's permission sync clear it immediately, so newly seeded permissions take effect right away under Octane.

### Changed

- **Datatable column visibility/order preferences moved from `sessionStorage` to `localStorage`.** If you've customized which columns are shown/hidden or reordered in a `SkDatatable`, that preference resets once after upgrading — no data loss, purely cosmetic, and you can just re-set it.

## 2026-07-04 — v13.6.8

### Quality & UX sprint

A broad quality-control pass: audit-log coverage, install/upgrade DX, accessibility, and a login-throttle security fix. One published-file change needs `sk:update` — see [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).

#### Security

- **`login_throttle = '0'` no longer fully disables the web login rate limiter** — it now swaps to a deliberately generous floor limiter instead of removing throttling entirely, so no admin setting can leave web login unthrottled.

#### Added

- **Audit log now covers role/permission changes, Settings, API Clients/Tokens, share links, and Content Languages** — these were previously invisible outside a log file (or not logged at all); they now show up in the ActivityLog admin screen. Setting values are never logged, only which keys changed.
- **`sk:install` can resume after a failure** — `php artisan sk:install --resume` picks up exactly where an interrupted install left off, and a failed step now prints a clear message instead of a raw stack trace. A Node.js version check runs up front so an old/missing Node produces a warning, not a cryptic crash mid-install.
- **`sk:doctor` checks Node.js version and whether a queue worker is actually running**, and no longer silently reports "OK" when it can't detect a cron heartbeat. Individual checks now time out instead of being able to hang the whole command.
- **`sk:eject` now asks for confirmation** before ejecting a domain (unless you pass `--force`/`--dry-run`/`--no-interaction`) — ejecting means the domain stops receiving kit updates, so it's no longer a silent one-way door.
- **Datatable is keyboard-accessible** — sortable headers, the search-clear button, and filter-remove buttons all work with Tab + Enter/Space now, and an empty table tells you whether that's because there's no data or because your filter matched nothing (with a one-click "clear filters").
- **Forms are safer to use** — double-submitting is now impossible, leaving a form with unsaved changes prompts a confirmation, a failed data/option load shows a retry option instead of failing silently, and required fields are announced to screen readers.
- **FileManager shows overall upload progress** across multiple simultaneous uploads, and the image lightbox supports arrow-key navigation between images.

#### Changed

- CI now fails the build on lint errors instead of only warning.
- `--no-interaction` installs get a fresh random admin password (printed at the end) instead of the old fixed `password`.
- A handful of backend consistency cleanups (centralised 422 error mapping, shared definition-controller logic, unified definition cache invalidation) with no visible behavior change.

## 2026-07-03 — v13.6.7

### Rich-text editor's empty area is now clickable

A single targeted CSS fix — no API or setup change.

#### Fixed

- **Clicking below the last line of text in the rich-text editor did nothing** — `EditorInput.vue` sets `minHeight` as inline `min-height` on the editor's wrapper, but the inner ProseMirror element used `height: 100%`. Percentage heights only resolve against a parent with a definite height, so ProseMirror only grew to fit its own content — the rest of the visually-tall box sat outside the real `contenteditable` region, so clicking or typing there was ignored. The wrapper is now a flex column and ProseMirror uses `flex-1` instead, so the editable area fills the whole configured height.

## 2026-06-20 — v13.6.6

### Activity log accepts UUID and numeric subjects

A single targeted database fix — no API or setup change.

#### Fixed

- **`sk:seed-permissions` no longer crashes with a uuid cast error** — the activity-log table created its polymorphic `subject_id` / `causer_id` columns as native `uuid`. But the kit logs activity on `User` (uuid key) **and** on the Spatie `Permission` / `Role` models (numeric/bigint keys), so seeding permissions failed with `SQLSTATE[HY000] 4078: Cannot cast 'bigint' as 'uuid'`. A new migration widens both id columns to `char(36)`, which stores a 36-char uuid and any numeric id alike — one polymorphic column now fits every audited model. The migration converges every prior state (native uuid, legacy bigint, legacy char(36)) to `char(36)`; existing apps pick up the fix on the next `php artisan migrate`.

## 2026-06-14 — v13.6.5

### Translation bundle now ships with the package

A packaging fix — no API or setup change.

#### Fixed

- **Fresh installs no longer show raw translation keys** — the pre-compiled kit translation bundles (`resources/js/lang/php_{en,tr}.json`) were listed in `.gitignore`, so they never entered Git and were absent from the Composer dist (which is a `git archive` of tracked files only). A freshly installed app received only the build script, not the bundles, so every kit i18n key (`sk-menu.*`, `sk-setting.*`, …) rendered as its raw key instead of the translated label. The two bundles are now tracked and shipped. The consumer does not build the package, so — unlike the consumer-built theme bundle — these must be committed to reach `vendor/`; the build script's own docs already specified "COMMITTED and shipped".

## 2026-06-14 — v13.6.4

### Datatable inline filter dropdown fix

A single targeted fix — no API or setup change.

#### Fixed

- **Inline filter dropdown no longer clipped** — a select filter's inline pill menu was rendered as an `absolute` element inside the table card, so a long option list was cut off at the card / scroll-container `overflow` edge. The menu is now teleported to `<body>` as a fixed overlay (the same approach PrimeVue's own `Select` uses via `appendTo`): it is positioned from its trigger, re-aligns on scroll/resize, caps at `min(60vh, 420px)` with its own scroll, and closes on outside-click / Escape. The `panel`-placement popover variant is unchanged (it already rides PrimeVue's overflow-visible portal).

## 2026-06-13 — v13.6.3

### Admin UI polish

A round of admin-panel UI refinements — no API or setup change.

#### Changed

- **Aura sidebar footer is a version pill** — the aura-theme sidebar footer became a single-row pill card: a green status dot and the app name on the left, the version (monospace) pushed to the right edge, with the same left/right inset as the nav item cards above it. Scoped to the aura theme only; the `main` theme footer is unchanged.
- **Account menu drops the external-link arrow on plain links** — the topbar user/account dropdown no longer shows the hover `↗` arrow on ordinary link items (My Profile, Account Settings, Change Password, Help, Logout). Submenu items keep their chevron and the active language keeps its check mark.
- **Datatable filter popover is panel-only** — the funnel button and its popover now appear only when a filter uses `panel` placement; `inline()` filters are no longer duplicated inside the popover. The **Activity Logs** page now renders all three filters (Event, Model, Date) inline in the toolbar, so its funnel/popover is gone entirely.

#### Fixed

- **`sk:install` / `sk:update` banner version label** — the installer/updater header now reads `v13.6.x` (was the stale `v13.5.x`). Cosmetic only; the historical `v13.5.0+` behaviour notes are unchanged.
- **Datatable `value`-mode tags resolve i18n keys at render time** — `tagLabels()` values are translated when the cell renders, not when the builder runs. The builder is built in a page's `<script setup>` body before the i18n bundle loads, so an eager `trans()` there froze the raw key (the Content Languages table showed `sk-content-languages.directions.ltr` instead of "Left to right (LTR)"). Literal (non-key) labels are unaffected since `trans()` returns them unchanged.
- **Content Languages form — spurious required asterisks removed** — FormBuilder fields are required by default, so `flag`, `fallback_code` and `sort_order` (all `nullable` server-side) drew a red `*`. They are now `.optional()`, matching their validation rules; `code`, `name`, `native_name`, `direction` keep the asterisk.

## 2026-06-13 — v13.6.2

### Admin panel layout & form alignment fixes

A batch of `main`-theme polish fixes for the admin panel — no API or setup change, just visual correctness.

#### Fixed

- **Roles form basics is a responsive 3-column grid** — the name / display-name / tag-color fields now sit side by side (`FB.form().cols(3)`) and stack vertically on small screens instead of always being full-width.
- **Permissions table is flush to its card** — the roles permission matrix uses the `SkCard` `flush` prop, so its row borders reach the card edges instead of floating inside the body padding (cells keep their own inner padding).
- **Translatable field inputs align with siblings** — the locale tab pills (`TranslatableInput`) were taller than a plain label and pushed their input down; the pills now match the plain-label height, so every input in a grid row starts on the same line.
- **Sidebar no longer crushes rows** — with multiple menu groups expanded the nav compressed its children before scrolling; direct children are now `shrink-0`, so overflow scrolls instead of overlapping.
- **Sidebar footer aligns with the page footer** — the sidebar footer height is pinned to `h-footer` (56px) so its top border lines up with the page footer's border at the bottom of the screen.

#### Changed

- **Security settings sub-tab "Cloudflare Turnstile" → "Bot Protection"** — the security sub-tab label (EN/TR) and the related `SecurityTab` section now use the provider-neutral name.

## 2026-06-13 — v13.6.1

### `sk:update` self-heals stale component imports

A single targeted fix — no API or setup change.

#### Fixed

- **`sk:update` no longer leaves stale imports behind after a component moves to vendor** — when a component moves out of stubs into `@lvntr/components`, its old local copy is force-deleted, but user-customized pages that still imported the deleted local path were left untouched and broke the Vite build with an `ENOENT` load-fallback error (e.g. `@/components/Auth/TurnstileWidget.vue`). `sk:update` now rewrites such stale import specifiers to the vendor path (`@lvntr/components/ui/TurnstileWidget.vue`) across `resources/js`, completing the migration that started in v13.6.0 on existing consumers' customized Auth pages (`Login`, `Register`, `ForgotPassword`).

---

## 2026-06-13 — v13.6.0 (continued)

### Vendor-first Phase 2 — Settings-tab controllers, Definitions/Media, ContentLanguage

Phase 2 finishes the vendor-first move started in Phase 1 by relocating the remaining controllers that back the vendor Settings tabs plus two API/Service controllers, and fully vendorizing the ContentLanguage domain. The Vue and migrations were already vendor — this is a PHP-layer-only move. Fresh installs receive no app copies of these files; existing installs are migrated by `sk:update` under the same hash guard.

#### Changed

- **Vendor-first HTTP layer for ApiClient, ApiToken, SystemHealth, ContentLanguage, Definitions (Api + Service), and MediaUpload** — these controllers (plus their FormRequests / API Resources where present) now live in `Lvntr\StarterKit\Http\...` and are aliased back to their `App\Http\...` FQCNs for backward compatibility. An app copy disables the alias automatically so your customisation continues to win. Route names, permission keys, and the Passport secret single-reveal are unchanged.
- **ContentLanguage domain vendorized** — `Actions` / `DTOs` / `Queries` moved to `Lvntr\StarterKit\Domain\ContentLanguage\`. The `App\Models\ContentLanguage` model stays app-owned (never aliased — keeps policy discovery + route-model binding intact); vendor code references it by `App\` FQCN.

#### Added

- **`sk:eject` gains five entries** — `SystemHealth`, `ContentLanguage`, `Definitions`, `MediaUpload`, and a full-HTTP-layer `ApiClient` (which also ejects the `ApiToken` controller/request/resource). The ejectable domain count rises from 10 to 14.

#### Migration

Run `composer update lvntr/laravel-starter-kit && php artisan sk:update`. See `docs/UPGRADE.md` (v13.5.11 → v13.6.0, "Behavior-module HTTP layer moved to vendor — Phase 2").

---

## 2026-06-13 — v13.6.0 (continued)

### Behavior-module HTTP + Vue layers moved to vendor; `sk:eject` supports `Files`

Five built-in admin modules — **Files, Logs, ActivityLogs, ApiRoutes, Settings** — now run their controllers, FormRequests, and Vue admin pages entirely from the vendor package. Fresh installs receive no app copies of these modules. Existing installs are migrated by `sk:update` under a hash guard (unmodified copies removed; modified copies preserved and reported). Vue migration additionally requires the `@lvntr/pages` vendor-fallback glob in `app.ts`.

#### Added

- **`sk:eject Files`** — ejects the FileManager admin Vue pages (`resources/js/pages/Admin/Files/`) into your app for UI customisation. The FileManager backend (controller, FormRequests, route-registry infrastructure) always stays vendor-managed; only the Vue layer is copied. Reverting deletes the copied pages and the vendor copy resumes via `app.ts` fallback.
- **Vendor-first HTTP layer for Logs, ActivityLogs, ApiRoutes, Settings** — controllers and FormRequests now live in `Lvntr\StarterKit\Http\...` and are aliased back to `App\Http\Controllers\Admin\*` for backward compatibility. An `app/Http/Controllers/Admin/SomeController.php` file in your app disables the alias automatically so your copy continues to win.
- **Group-atomic migration in `sk:update`** — vendor-first modules are migrated per layer (PHP and Vue independently). If any file in a layer is modified, the entire layer is preserved. No half-deleted module is ever produced.

#### Changed

- **`sk:eject` manifest extended** — `Files` domain added (Vue-only: `backend: ''`). The available domain list in the command signature now includes `Files`.

#### Migration

Run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm run build`. For a customised module, `sk:update` preserves it and reports it; run `sk:eject <Module>` to take full explicit ownership. See `docs/UPGRADE.md` (v13.5.11 → v13.6.0, "Behavior-module HTTP + Vue layers moved to vendor") for the three-scenario guide.

---

## 2026-06-11 — v13.6.0 (continued)

### Install-time domain eject for User + Role

Fresh installs now automatically eject the `User` and `Role` domain runtime into `app/Domain/`. These are the two domains most often customised, so they arrive as project-owned files without any extra step.

#### What changes on a fresh install

- `app/Domain/User/` and `app/Domain/Role/` are created with backend classes (Actions, DTOs, Queries, Events, Listeners) rewritten to the `App\Domain\` namespace.
- `DomainServiceProvider` receives the `Event::listen` bindings for the six audit events so activity logging continues without interruption.
- Future `composer update` runs do not touch these directories — you own them.

#### Opting out

```bash
php artisan sk:install --without-eject
```

Both domains remain vendor-resident and resolve via `class_alias`. You can run `sk:eject User` / `sk:eject Role` manually at any time.

#### Reverting after install

Delete `app/Domain/User/` and `app/Domain/Role/`, remove the injected `Event::listen` lines from `app/Providers/DomainServiceProvider.php`, and run `composer dump-autoload`.

#### Existing installs

No change. The eject step runs only when `storage/starter-kit/hashes.json` does not yet exist (first install). On existing installs the registry is already present so the step is skipped. Existing projects are unaffected.

#### New flag on `sk:eject`

`sk:eject` gains a `--skip-autoload` flag used internally by the installer so it does not run `composer dump-autoload` per-domain (the installer runs one consolidated dump after all ejects complete). This flag is not needed for normal manual `sk:eject` use.

---

## 2026-06-06 — v13.6.0

### Minor release — Vendor-runtime migration completed + structured theme/layout/CSS system

13.6.0 bundles every change made since the last published release (v13.5.11) into a single version. It completes the "package runtime runs from vendor" migration on both the backend and the frontend, and reorganises the admin-panel layout and CSS into a structured, override-ready theme system. **No visual change** — the default build (`VITE_SK_THEME=main`) is byte-identical to v13.5.11. The sections below group the bundled changes by area.

### Permission directive plugin resolves from vendor

The `v-can` / `v-role` Vue plugin (`resources/js/plugins/permission.ts`) is now served from the vendor package by default — the same resolution kit composables already use. A `@/plugins/<name>` import resolves to your local copy when one exists, otherwise it falls back to the vendor copy, so the kit can ship directive fixes without a stub re-copy. **No behavior change**: the directives are identical and `app.ts` still imports `@/plugins/permission` unchanged.

#### Changed

- **`resources/js/plugins/permission.ts`** — moved into the vendor package. The dead, unused `useCan()` export was dropped (the live composable is `@/composables/useCan`); the file now ships only `PermissionPlugin` (the `v-can` / `v-role` directives), with no auto-import dependency.
- **`vite.config.ts`** — new `@/plugins/*` alias `customResolver` (`resolvePlugin`) mirroring `@/composables/*`: local-override-then-vendor fallback, ordered before the bare `@` alias.
- **`tsconfig.json`** — new `@/plugins/*` path mapping (local + vendor).

#### Added

- **`sk:publish --tag=plugins`** — publish a local, editable copy of the Vue plugins to customise the permission directives.

#### Migration

No action required — resolution is automatic. Your existing `resources/js/plugins/permission.ts` keeps shadowing the vendor copy; delete it to adopt the vendor version. See `UPGRADE.md`.

---

### All CSS cascade layers are now override slots

Every CSS layer in the theme system is now an overridable slot. Previously `fonts.css`, `_base.scss`, `_auth.scss`, and `utilities.css` were fixed imports outside the resolver; they now live under `themes/main/` and are emitted by `scripts/sk-theme-build.mjs` in the correct cascade order alongside `tokens`, `layout/*`, and `components/*`. **No visual change** — the default build with `VITE_SK_THEME=main` is byte-identical to v13.5.11. The only difference is that a `custom` theme can now override any layer, including fonts, base reset, auth styles, and utility overrides, by placing a matching file under `themes/custom/`.

#### Changed

- **`themes/main/fonts.css`**, **`themes/main/_base.scss`**, **`themes/main/_auth.scss`**, **`themes/main/utilities.css`** — moved from `resources/css/theme/` root into `themes/main/`. Content unchanged.
- **`scripts/sk-theme-build.mjs`** — HEAD slots (`tokens.css`, `fonts.css`, `_base.scss`) and TAIL slots (`_auth.scss`, `utilities.css`) are now resolved through `resolveSlot()` (same override-or-main fallback used by `layout/*` and `components/*`). The cascade order is preserved: `tokens → fonts → _base → layout/* → components/* → _auth → utilities`.
- **`theme/theme.css`** — now contains only `@import './_active.css'`; the former fixed `_auth.scss` import is gone.
- **`app.css`** — the former fixed `utilities.css` tail import is gone; `utilities.css` is now the last slot emitted by the resolver.
- **`themes/custom/README.md`** — updated to list all overridable slots including `fonts.css`, `_base.scss`, `_auth.scss`, and `utilities.css`.

#### Migration

No action required. `sk:update` delivers the updated files. The default build is byte-identical. To override a previously fixed layer, place the matching file under `themes/custom/` (e.g. `themes/custom/fonts.css`). See `docs/theme.md` — Complete slot reference.

---

### AppShell layout composition + build-time theme-override system (`themes/main` / `themes/custom`)

The admin-panel layout and CSS are reorganised into a structured, override-ready system. **No visual change** — the default build is byte-identical to the previous published version. The layout shell is split into a reusable `AppShell.vue` (structural backbone, sidebar state, named regions) and a thin `AdminLayout.vue` composition that wires in the standard admin components. The CSS monolith (`_admin.scss` + scattered `_*.scss` partials) is dissolved into a `themes/main/` directory tree of individual slot files. A new opt-in `themes/custom/` directory and `scripts/sk-theme-build.mjs` theme resolver enable per-slot overrides at build time: set `VITE_SK_THEME=custom`, place a file in `themes/custom/components/datatable.css`, and only that slot is replaced — everything else falls back to `main`. See `docs/theme.md` for the full reference and custom-override recipe.

#### Added

- **`AppShell.vue`** (`resources/js/layouts/AppShell.vue`) — reusable structural layout shell. Owns the `.admin-layout` / `.admin-main` / `.admin-content` skeleton and `useSidebar` state (single owner). Exposes five named slots: `#sidebar` (scoped: `collapsed`, `mobileOpen`, `isMobile`, `closeMobile`), `#header` (scoped: `collapsed`, `isMobile`, `toggle`), `default`, `#footer`, `#overlays`.
- **`themes/main/` CSS tree** — `tokens.css` (CSS custom properties, light + dark), `layout/{shell,sidebar,header,page-header,footer}.css`, `components/{card,confirm,datatable,dialog,editor,formbuilder,menus,navigation,primevue,tabs,tag,toast}.css`. Values are byte-identical to the removed partials.
- **`themes/custom/` skeleton** — empty override-theme directory with a `README.md` explaining the full-replacement + fallback model.
- **`scripts/sk-theme-build.mjs`** — theme resolver. Reads `VITE_SK_THEME` (default `main`); walks `themes/main/` for the canonical slot list; for each slot emits `themes/<active>/<slot>` if it exists, else `themes/main/<slot>`; writes `theme/_active.css`. Override slots are annotated `/* override */` in the output. Invoked as an explicit `&&` step in `dev` and `build` — not via npm lifecycle hooks — so it works correctly under `ignore-scripts=true`.
- **`npm run theme:build`** — standalone script alias for the resolver (also runs as an explicit step in `dev` and `build`).
- **`VITE_SK_THEME=main`** added to `.env.example` with inline documentation.
- **PrimeVue preset resolver** — `scripts/vite-plugin-sk-theme.mjs` now intercepts the `@/theme/preset` import at build time and resolves it to `resources/js/theme/themes/<active>/preset.ts` when that file exists, otherwise falls back to the base `resources/js/theme/preset.ts`. The base file stays in place — no consumer migration required. The `resources/js/theme/themes/custom/` skeleton ships empty so the default build is byte-identical to the previous version.
- **`docs/theme.md` + `docs/theme.tr.md`** — updated: two-layer overview table (CSS override vs PrimeVue preset), PrimeVue preset layer section with directory layout, custom-palette recipe, and dependency-chain note (`tokens.css` reads `--p-*` variables).

#### Changed

- **`AdminLayout.vue`** refactored into a thin `AppShell` composition. External prop/slot contract (`title`, `subtitle`, `backUrl`, `default`, `page-actions`) is **unchanged** — all existing pages continue to work without modification.
- **`theme.css`** now imports a single `_active.css` instead of an explicit list of partials. Import order is preserved.
- **`_base.scss`** retains only base/reset rules; the `:root` / `.dark` CSS custom-property blocks moved to `themes/main/tokens.css`.

#### Removed

- **`_admin.scss`** — replaced by `themes/main/layout/*`.
- **`_datatable.scss`, `_formbuilder.scss`, `_dialog.scss`, `_toast.scss`, `_tag.scss`, `_card.scss`, `_editor.scss`, `_tabs.scss`, `_menus.scss`, `_navigation.scss`, `_confirm.scss`, `_primevue.scss`** — replaced by `themes/main/components/*`.

#### Fixed

- **Theme resolver now works under `ignore-scripts=true`** — the resolver is chained directly into the `dev` and `build` scripts (`node scripts/sk-theme-build.mjs && vite …`). Previously it ran as `predev` / `prebuild` lifecycle hooks, which npm silently skips when `ignore-scripts=true` is set (common in consumer projects and CI), causing `_active.css` to be absent and the build to hard-fail. The `predev` and `prebuild` entries have been removed.

#### Migration

`sk:update` delivers all new stubs. No migration required if no moved file was customised — `npm run build` produces a byte-identical panel. If you customised a moved file, copy your changes into the corresponding `themes/main/` slot or use `themes/custom/` for an isolated override. See `docs/UPGRADE.md` (v13.5.11 → v13.6.0) for details.

---

### Kit composables run from vendor; local-first resolver; `sk:publish --tag=composables`

v13.5.12 moves 15 kit composables out of the stub scaffold and into the vendor library. They now run directly from `vendor/lvntr/laravel-starter-kit/resources/js/composables/` and are updated with every `composer update`. Import paths are fully unchanged — `@/composables/<name>` resolves local-first (consumer file wins if it exists) then falls back to the vendor copy, so no consumer import statement needs to change. `useAdminMenu` and `index.ts` remain as editable stubs because they depend on the consumer's generated routes and project-specific menu definition. `TurnstileWidget.vue` was likewise moved to the vendor library (`@lvntr/components/ui/TurnstileWidget.vue`).

#### Added

- **15 composables in vendor** — `useApi`, `useCan`, `useConfirm`, `useDarkMode`, `useDatatableSelection`, `useDefinition`, `useDialog`, `useFileShare`, `useFlash`, `useImageLightbox`, `useMenuBuilder`, `usePageLoading`, `useRefreshBus`, `useSidebar`, `useUrlTab` shipped inside the package. Updated via `composer update` — no manual file management.
- **`sk:publish --tag=composables`** — copies vendor composables into `resources/js/composables/` for project-level customization. The local-first resolver picks up the local copy automatically; no alias or build-config changes required.
- **`TurnstileWidget.vue` moved to vendor** — now available at `@lvntr/components/ui/TurnstileWidget.vue`.

#### Removed

- **15 composable stubs** — removed from the scaffold. Existing projects are unaffected (local-first resolver keeps using local copies). To opt into vendor-managed upgrades: delete unmodified composable files from `resources/js/composables/`, keeping `useAdminMenu.ts`, `index.ts`, and any file you have customized.

### Backend runtime classes & third-party configs run from vendor

The same release continues the v13.5.0 "runtime runs from vendor" migration on the backend. A set of helper classes, validation rules, and middleware moved out of the published scaffold into the vendor package, and three third-party config files are no longer copied into your app. Existing apps are not affected — `App\…` imports keep resolving (via `class_alias` for pure-moved classes, or a thin `App\` shim for the rest), and a config you already published keeps winning. The only required step is `composer update`. See `docs/UPGRADE.md` (v13.5.11 → v13.6.0) for the full migration guide.

#### Added

- **Vendor-resident backend classes** — `HtmlSanitizer`, `TranslatableQueryHelpers`, `MediaPathGenerator`, `Scramble\ApiResponseExtension`, and the `AssignTraceId` / `SetLocale` / `ValidateTurnstile` middleware now run from `Lvntr\StarterKit\*`. No stub is copied to the app; old `App\…` imports resolve via `class_alias`. `ApiResponseExtension` is now properly registered with Scramble.
- **Vendor classes with a thin `App\` shim** — `DatatableQueryBuilder`, `HttpsOrLocalhostUrl`, and `TurnstileRule` keep their `App\…` import path while running from vendor.
- **`HasTranslatableRules` trait → vendor (direct import)** — now `Lvntr\StarterKit\Support\HasTranslatableRules`. Traits cannot be aliased, so import it from the vendor namespace (same convention as `HasActivityLogging` / `HasMediaCollections`).

#### Changed

- **Third-party config overrides at runtime** — `config/activitylog.php`, `config/inertia.php`, and `config/media-library.php` are no longer published. `StarterKitServiceProvider::applyVendorConfigDefaults()` applies only the kit's required keys (media-library `path_generator` + `media_model`, activitylog `include_soft_deleted_subjects`, inertia `ssr.enabled`) at runtime and skips any config you have published. The installer no longer AST-injects the media-library path generator.

#### Removed

- **Backend scaffold stubs** — `app/Support/{HtmlSanitizer,TranslatableQueryHelpers,MediaPathGenerator,HasTranslatableRules}.php`, `app/Support/Scramble/ApiResponseExtension.php`, `app/Http/Middleware/{AssignTraceId,SetLocale,ValidateTurnstile}.php`, and `config/{activitylog,inertia,media-library}.php` removed from the scaffold. Upgraded apps keep existing copies (informational notice from `sk:update`, never auto-deleted). For the `HasTranslatableRules` trait, switch `use` imports to the vendor namespace before deleting a local copy.

## 2026-06-04 — v13.5.11

### Patch release — Standalone 3-skill set replaces monolithic bundled skill

v13.5.11 removes the previous 723-line monolithic skill (`stubs/.claude/skills/lvntr-starter-kit/SKILL.md`) and replaces it with three focused, self-contained skills distributed under `stubs/.claude/skills/`. The new skills require no additional tooling and cover the three main concerns of a starter-kit project: core rules, backend/DDD conventions, and frontend builder patterns.

`sk:install` gains a `--without-ai-skill` flag for projects that prefer not to publish any AI skill files.

#### Added

- **`stubs/.claude/skills/lvntr-starter-kit/`** — core skill: hard rules, recipe pointers, permissions/i18n config, cross-domain `references/` links.
- **`stubs/.claude/skills/lvntr-kit-domain/`** — backend / DDD skill: Actions, Services, FormRequest, Resource, Repository conventions, and domain boundary guidance.
- **`stubs/.claude/skills/lvntr-kit-frontend/`** — frontend skill: FormBuilder / DatatableBuilder / TabBuilder patterns, composables (`useApi`, `useDialog`, `useForm`), and starter-kit component rules.
- **`sk:install --without-ai-skill`** — opt-out flag; skips publishing the skill files to the host application.

#### Removed

- **`stubs/.claude/skills/lvntr-starter-kit/SKILL.md`** — the 723-line monolithic skill has been removed. If you published the old file to your host application, delete `.claude/skills/lvntr-starter-kit/SKILL.md` before re-running `vendor:publish`.

## 2026-05-30 — v13.5.10

### Patch release — SkCard primitive, card title actions slot, caption divider, SkForm grid span + 12-column support, and SkColorSelector neutral palette

v13.5.10 introduces `SkCard`, a shared wrapper around PrimeVue Card that provides a single source of truth for the kit's card surfaces. The same patch adds two consumer-facing slots powered by it: `#title-end` on `SkForm`'s root card and per-section `#section-${key}-title-end` on every `FB.section()` card. Both render to the **right** of the title in the same row, ready to host action buttons, status badges, or contextual indicators. The section slot is scoped — it exposes `{ values }` (a reactive snapshot of the current form values) so consumers can render conditionally. `SkCard` itself accepts `title`, `subtitle`, `transparent`, `divider`, and `pt` props plus `header`/`title`/`subtitle`/`content`/`footer`/`title-end` slots; class fallthrough works via `inheritAttrs: false` + `useAttrs` because PrimeVue Card opts out of attribute inheritance on its own root. `SkForm.vue` and `SkFormFieldRenderer.vue` were refactored to use `SkCard` instead of `<Card>` directly — their `cardPt`/`transparentCard`/`sectionCardPt` helpers are gone, the title flex wrapper and caption bottom-divider styles moved out of `_formbuilder.scss` into `_card.scss` (`.sk-card--divider .p-card-caption`), and now any consumer that wraps content in `SkCard` gets the same caption header behavior (title text on the left, `#title-end` on the right, subtitle below, divider underneath the caption block).

The same release also adds field-level grid span control to `SkForm`: `BaseFieldConfig.colSpan` lets any field (or a field inside a section) declare how many columns it occupies in the form grid, so layouts like "full-width title + two fields side by side" are now possible, and `.cols()` supports the full 1–12 range instead of falling back above 6. `SkColorSelector` gains the 5 neutral Tailwind families (`slate`, `gray`, `zinc`, `neutral`, `stone`), bringing the total palette to 22 families.

#### Added

- **`BaseFieldConfig.colSpan?: number`** — sets how many columns a field (or a field inside a section) spans in the form grid (`1..cols`). Omitted → existing behavior (1 cell). Values exceeding `cols` are clamped automatically; inside a section the clamp uses `sectionCols` (the section's own `cols`, or the form `cols`).
- **`BaseFieldBuilder.colSpan(n: number)`** — chainable `.colSpan(n)` added to every field builder. Example: `FB.inputText().key('title').label('Title').colSpan(12)`.
- **`SkColorSelector` — 5 neutral Tailwind families** — `slate`, `gray`, `zinc`, `neutral`, `stone` added with all 50–950 shades (official Tailwind v4 hex). Total palette: 22 families.
- **`SkCard` UI primitive** — `resources/js/components/Lvntr-Starter-Kit/ui/SkCard.vue`. Shared wrapper around PrimeVue Card used by `SkForm` (and intended for future `SkDatatable` / page-level cards) so caption behavior, the `#title-end` slot, and the bottom divider have a single implementation.
  - Props: `title?: string`, `subtitle?: string`, `transparent?: boolean` (default `false` — `true` removes background/shadow/padding, useful inside dialogs or nested cards), `divider?: boolean` (default `true` — draws a bottom border under the caption block), `pt?: Record<string, any>` (merged into the PrimeVue Card pt; consumer keys win on conflicts).
  - Slots: `header`, `title`, `subtitle`, `content` (the default slot also maps to content), `footer`, **`title-end`** (right-aligned action/badge/status slot).
  - `inheritAttrs: false` + `useAttrs` so outer `class` fallthrough still reaches the Card root (PrimeVue Card sets `inheritAttrs: false` on its own root, which otherwise blocks class propagation).
  - Exported from `index.ts` as `SkCard`.
- **`SkForm.vue` — `#title-end` slot** — new slot rendered to the right of the form-level card title. Use it for action buttons, badges, or status indicators that should live in the same row as the heading. The slot is only rendered when content is provided.
- **`SkFormFieldRenderer.vue` — per-section `#section-${key}-title-end` slot** — scoped slot rendered to the right of each section card title. Because `SkForm.vue` already forwards every slot via the generic `v-for $slots` pattern, consumers use it directly on `<SkForm>` as `<template #section-address-title-end="{ values }">`. Slot scope: `{ values }` — a reactive snapshot of the current form values, useful for conditional rendering.
- **Docs** — new "SkCard" section in `docs/ui-components.md` and `docs/ui-components.tr.md`; new "Card Title Actions Slot" / "Card Başlık Sağ Slot" section in `docs/formbuilder.md` and `docs/formbuilder.tr.md`.

#### Changed

- **`SkForm.vue` — root `<Card>` → `<SkCard>` refactor** — the internal `cardPt` computed and `transparentCard` style constant were removed; `:transparent="isTransparentCard"` is passed to `SkCard` instead. The form card's title and subtitle are now passed via `:title` and `:subtitle` props; the flex title wrapper and caption bottom-divider are produced once inside `SkCard`.
- **`SkFormFieldRenderer.vue` — section render switched to `<SkCard>`** — `sectionCardPt` replaced with a `sectionIsTransparent` helper + `:transparent` prop. The section title flex wrapper and `title-end` slot are now delegated to `SkCard`; the icon-bearing title (`SkIcon` + text) is rendered directly inside `SkCard`'s `#title` slot.
- **`RenderCtx` (SkFormFieldRenderer.vue) — `transparentCard` field removed** — `SkCard`'s `transparent` prop is the only source of truth; the floating style constant in the context object is no longer needed.
- **`stubs/resources/css/theme/_card.scss`** — added `SkCard` styles:
  - `.sk-card__title-row` (flex w-full justify-between, title row)
  - `.sk-card__title-text` (title text, inline-flex for icon alignment)
  - `.sk-card__title-end` (right slot container, shrink-0)
  - `.sk-card--divider .p-card-caption` (`pb-3 mb-1 border-b` under the caption block + `--p-surface-200` / `--p-surface-700` dark variant). Only triggers inside `SkCard` — other PrimeVue Card usages stay untouched.
- **`stubs/resources/css/theme/_formbuilder.scss`** — the transitional selectors that this work originally introduced (`.sk-fb__card*`, `.sk-fb__section-title-wrapper`, `.sk-fb__section-title-end`, `.sk-fb__card .p-card-caption`, `.sk-fb__section .p-card-caption`) were removed. A short note now points to `_card.scss`.
- **`SkForm.vue` — `colsClassMap` extended to 1–12** — values 7–12 previously fell back to the default grid; `cols(7)`–`cols(12)` now apply `md:grid-cols-N` directly.
- **`SkForm.vue` + `SkFormFieldRenderer.vue` — `colSpanClassMap`** — purge-safe static map added; top-level and in-section field wrappers receive `md:col-span-N` based on `colSpan`. Fields without `colSpan` render identically to before (no regression).

## 2026-05-21 — v13.5.9

### Patch release — SkIcon primitive, section/card grouping, and icon APIs

v13.5.9 introduces `SkIcon`, a package-agnostic icon renderer that auto-detects three formats from a single `icon: string` prop: raw SVG (`v-html`), image URL (`<img>`), or class-based icon (`<i :class>` — works with PrimeIcons, FontAwesome, MDI, Lucide, Iconify, and any other CSS icon library). A unified icon API lands across all field types via `BaseFieldConfig`: `labelIcon` / `labelIconPosition` place an icon beside the label in any layout, while `icon` / `iconPosition` place one inside the input (supported on `input-text`, `input-number`, `input-mask`, and `password` without feedback). Title fields gain their own `icon` / `iconPosition` pair. The headline feature is `SectionFieldConfig` (`type: 'section'`) and its `FB.section()` fluent builder — fields can now be visually grouped inside a PrimeVue Card with a title, subtitle, icon, and configurable column count, while the form payload stays flat (section keys are never emitted). `SkForm.vue` gained a `flatFields` computed backed by an `iterateAllFields` generator so sections are transparent to all existing field-processing logic (file upload keys, date transforms, definition preloads, dynamic selects). `SkFormFieldRenderer.vue` was extracted to handle recursive rendering and slot forwarding. `InputTextFieldConfig.icon` / `iconPosition` are now deprecated in favour of the base-level API.

#### Added

- **`SkIcon` UI primitive** — package-agnostic icon renderer. Auto-detects from a single `icon: string` prop: `<svg…` → raw SVG (`v-html`), `^(https?:|data:)` → `<img>`, otherwise → `<i :class>` (PrimeIcons, FontAwesome, MDI, Lucide, Iconify and any class-based icon set). **Security:** `icon` must only be passed from builder config (developer-controlled) — user-sourced strings are an XSS risk (the `<svg…` path uses `v-html`).
- **`BaseFieldConfig` icon fields** — shared icon API for all field types:
  - `labelIcon?: string` + `labelIconPosition?: 'left' | 'right'` (default: `'left'`) — icon beside the label in all layout paths.
  - `icon?: string` + `iconPosition?: 'left' | 'right'` (default: `'left'`) — icon inside the input. Supported types: `input-text`, `input-number`, `input-mask`, `password` (custom path — no icon when `feedback: true`). `groupPrefix`/`groupSuffix` take precedence — input icon is disabled when they are present.
- **`TitleFieldConfig` icon fields** — `icon?: string` + `iconPosition?: 'left' | 'right'`. Example: `FB.title('General').icon('pi pi-info-circle')`.
- **`SectionFieldConfig` (new field type `type: 'section'`)** — visual field grouping inside a Card:
  - `title?` (translation key, falls back to `label`), `subtitle?`, `icon?`, `iconPosition?`
  - `cols?: number` (default: parent form's `cols`)
  - `fields: FieldConfig[]` (nested — one level only; nested sections are not supported)
  - `isCard?: boolean` (default: card visible; `false` → transparent Card)
  - **Form payload stays flat** — the section's `key` is never emitted; sections are a purely visual grouping primitive.
- **`SectionBuilder` and `FB.section(title?)` factory** — fluent API: `.title(t)`, `.subtitle(s)`, `.icon(str)`, `.iconPosition(p)`, `.cols(c)`, `.isCard(enabled)`, `.addFields(...)`.
- **`BaseFieldBuilder` fluent methods** — `.labelIcon(str)`, `.labelIconPosition(p)`, `.icon(str)`, `.iconPosition(p)` now available on all field builders (moved from `InputTextBuilder` to base — same signature, no behaviour change).
- **`TitleBuilder.icon()` and `.iconPosition()`** methods.
- **`SkFormFieldRenderer.vue`** — extracted recursive field renderer. Section render, slot forwarding, and label/title icon rendering are now handled here; `SkForm.vue`'s template is simplified.
- **Docs** — 5 new sections in `docs/formbuilder.md` and `docs/formbuilder.tr.md`: Icons (Package-Agnostic), Label Icons, Input Icons, Title Icons, Section / Card Grouping. XSS security note in both languages.

#### Changed

- **`AppDialog.vue` — `confirmSeverity` no longer defaults to `'primary'`** — `state.footer?.severity ?? 'primary'` → `state.footer?.severity`. The confirm button now falls back to PrimeVue Button's own default appearance (from the theme preset). Existing dialogs that did not explicitly set `DialogFooter.severity` may see a visual change.
- **`useDialog.ts` — `DialogFooterSeverity` type widened** — `'primary'` removed (not a valid PrimeVue Button severity); `'info'`, `'help'`, `'contrast'` added. Full list: `'secondary' | 'success' | 'info' | 'warn' | 'help' | 'danger' | 'contrast'`.
- **`SkForm.vue` — flat field iteration** — `derivedDefaults`, `currentValues`, `definitionKeys`, `dynamicSelectFields`, `hasFileFields`, `dateOnlyFields` computeds are now backed by the new `flatFields` computed (iterative `iterateAllFields` generator). Fields inside sections are automatically categorised correctly (file-upload existingMediaKey resolve, date-picker toLocalDateStr transform, definition preload, dynamic optionsUrl fetch). Forms without sections render identically to before (no regression).
- **`SkFormInput.vue` — generic input icon** — the `IconField` wrapping pattern previously only for `input-text` is now active for `input-number`, `input-mask`, and `password` (custom path). Icon descriptors render via `SkIcon`, so MDI / FA / Lucide / Iconify / SVG / img URL work in addition to PrimeIcons. `BaseFieldConfig.icon` takes precedence; `InputTextFieldConfig.icon` is kept as a legacy fallback.
- **`stubs/resources/css/theme/_formbuilder.scss`** — minimal `inline-flex items-center gap` added to `.sk-fb__title` and `.sk-fb__label` for icon alignment (line-height and padding unchanged). New sections: `SKICON & LABEL/TITLE ICONS` (`.sk-icon`, `.sk-icon--svg svg`, `.sk-icon--img`, `.sk-fb__label-icon`, `.sk-fb__title-icon`, `.sk-fb__section-icon` + `--left/--right` modifiers), `SECTION CARD` (`.sk-fb__section`, `.sk-fb__section-title`, `.sk-fb__section-field`).

#### Deprecated

- **`InputTextFieldConfig.icon` and `InputTextFieldConfig.iconPosition`** — use the new `BaseFieldConfig.icon` and `BaseFieldConfig.iconPosition` instead. Legacy fields are kept for backward compatibility (`SkFormInput.vue` uses `base ?? legacy` fallback and produces the same render); they will be removed in the next major version.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/_formbuilder.scss
# stubs/resources/js/composables/useDialog.ts  ← DialogFooterSeverity type changed
php artisan vendor:publish --tag=starter-kit-stubs --force
```

**`DialogFooterSeverity` breaking change:** `'primary'` is no longer a valid value. If you used `severity: 'primary'` in any `useDialog().open(...)` call, remove it (the Button will apply its own theme default) or replace it with a valid value such as `'secondary'` or `'contrast'`. TypeScript will already flag these as errors.

**Migration:** legacy `InputTextFieldConfig.icon` calls continue to work (deprecated, kept until removal). To use the new features:

```ts
// Label icon — any field type
FB.inputText().key('email').label('Email').labelIcon('pi pi-envelope')

// Input icon — input-text/number/mask/password
FB.inputText().key('search').icon('pi pi-search')                    // PrimeIcons
FB.inputText().key('user').icon('mdi mdi-account')                   // Material Design Icons
FB.inputText().key('star').icon('fa fa-star').iconPosition('right')  // FontAwesome
FB.inputText().key('logo').icon('https://cdn.example.com/icon.svg')  // URL

// Title icon
FB.title('General Info').icon('pi pi-info-circle')

// Section / Card grouping
FB.form()
    .isCard(false)
    .addFields(
        FB.section('Personal Info').icon('pi pi-user').cols(2).addFields(
            FB.inputText().key('first_name').label('First Name'),
            FB.inputText().key('last_name').label('Last Name'),
        ),
        FB.section('Address').icon('pi pi-map-marker').addFields(/* ... */),
    )
    .build();
```

---

## 2026-05-20 — v13.5.8

### Patch release — AppDialog Material Flat shell, rich header & footer API, scrollbar-gap fix

`AppDialog` has been redesigned around PrimeVue Dialog's `#container` template into a self-contained "Material Flat" shell: gradient icon lozenge + title + subtitle in the header, an optional slate-100 sticky footer with hint icon/text on the left and Cancel/Confirm buttons on the right, a softer dual-layer drop shadow, and a custom "rise" enter/leave animation. The shell is fully scoped (`sk-dlg` PT class) so `ConfirmDialog` and other Dialog usages remain untouched. The `useDialog` composable gained `subtitle`, `icon`, and `footer` open options, a new `DialogFooter` interface, and `setFooter()` / `patchFooter()` methods so components rendered inside the dialog can mutate the footer (e.g. flip the confirm button to a loading state) without re-opening. The remaining sticky-bar issue from v13.5.7 — a ~10 px white gap on the right side of the gray footer when the form scrolled — is fixed by hiding the dialog body's scrollbar visually; scroll still works via wheel / trackpad / keyboard, so the slate-100 bar now reaches the dialog's right edge cleanly.

#### Added

- **`AppDialog` Material Flat shell** — header now ships an icon lozenge (`state.icon`), title (`state.header`), and subtitle (`state.subtitle`); a slate-themed close button replaces PrimeVue's default. Optional opt-in footer renders a sticky slate-100 action bar with hint icon/text + Cancel/Confirm.
- **`useDialog` rich-header & footer API** — `OpenOptions.subtitle`, `OpenOptions.icon`, `OpenOptions.footer` added. New `DialogFooter` type with `icon`, `text`, `cancelLabel`, `confirmLabel`, `confirmIcon`, `severity`, `onConfirm`, `hideCancel`, `disabled`, `loading`. New `setFooter()` and `patchFooter()` methods.
- **`_dialog.scss`** — new stylesheet imported from `theme.css`; defines the shell (mask, root, head/lead/title-block, body, foot/info/actions) and is scoped via the `sk-dlg` PT class.

#### Changed

- **`preset.ts` modal token** — `borderRadius.xl` → `borderRadius.md` (6 px), `padding: 1.25rem` → `padding: 0` (shell-level padding handled inside `AppDialog`), drop shadow updated to a softer dual-layer (`0 24px 60px -20px ...`, `0 6px 20px -6px ...`).

#### Fixed

- **Form scrollbar gap on the right of the footer** — long forms inside `AppDialog` left a ~10 px white gap between the right edge of the slate-100 action bar and the dialog's right edge (the body's scrollbar consumed content width and the bar's `-mx-8` extension only reached the body's content edge). `.sk-dlg__body:has(.sk-fb--dialog)` now hides the scrollbar visually (`scrollbar-width: none` + `::-webkit-scrollbar { width: 0 }`); scroll continues to work via wheel / trackpad / arrow keys / Page Up–Down / Home–End.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/{_dialog.scss,_formbuilder.scss,theme.css}
# stubs/resources/js/composables/useDialog.ts
# stubs/resources/js/theme/preset.ts
php artisan vendor:publish --tag=starter-kit-stubs --force
```

**Behavioural note:** the Dialog body's scrollbar is intentionally invisible inside form dialogs. The visible track is hidden so the slate-100 action bar reaches the dialog edge with no gap; scroll continues to work via wheel, trackpad, arrow keys, Page Up/Down, Home/End.

---

## 2026-05-19 — v13.5.7

### Patch release — Dialog sticky bar bleed fix, AvatarUpload redesign, 14px root typography

`AppDialog`'s sticky form action bar (`Cancel` / `Update`) was leaking scrolling content out from under the buttons whenever a form exceeded the Dialog's visible height — the root cause was Dialog content's default `padding: 1.25rem` leaving a transparent gap underneath the sticky bar. Fixed by zeroing the Dialog's `padding-bottom` via the PT API, having `SkForm` advertise dialog mode through a `sk-fb--dialog` marker class, and extending the sticky bar edge-to-edge with a matching `rounded-b-xl` so the bar mirrors the Dialog's rounded corners. `AvatarUpload` has been redesigned from the previous stacked card to a single-row layout (avatar · title/hint · actions) with a smaller 56px avatar, primary border accent, and a new `initials` prop for showing user initials when no photo is uploaded. The `title` and `subtitle` props now have explicit three-state semantics: omit → default i18n, non-empty string → that text, empty string → row hidden entirely. Typography has been rebased to a 14px root system written in rem (so user browser font-size preferences and a11y zoom continue to scale proportionally); the previous absolute-px override is gone. Profile vertical tabs gained description text and per-tab icon colors.

#### Added

- **Profile tabs** — `Profile/Index.vue` now declares `description()` and `iconColor()` on each tab; `sk-profile.tab_descriptions.{general,password,security,sessions}` keys added (TR/EN).
- **`AvatarUpload :initials`** — renders user initials in the avatar slot when no `avatarUrl` is provided; falls back to `pi-user` otherwise.

#### Changed

- **`AvatarUpload` row layout** — avatar shrunk to `size-14`, primary-200 border on primary-50 background, "Remove" is `severity-secondary text`, "Change" is `outlined`. Title and hint render inline; the avatar block can be rendered without any caption by passing `:title=""` and/or `:subtitle=""`.
- **`AvatarUpload` `title` / `subtitle` semantics** — `undefined` → default i18n key, non-empty string → that text, `''` → element fully hidden via `v-if`. Restores the ability to opt out of the labels.
- **`sk-avatar.hint`** — copy reformatted to `"JPG · PNG · GIF — max 2 MB · 512×512 recommended"` (TR: `"JPG · PNG · GIF — en fazla 2 MB · 512×512 önerilir"`).
- **Typography (14px root, rem)** — `_base.scss` sets `html { font-size: 0.875rem }`; `utilities.css` declares all `--text-*` tokens in rem relative to that root (`--text-base: 1rem`, `--text-xs: 0.857rem`, etc). The transient absolute-px override from a previous WIP is replaced; a11y zoom now scales the whole UI again.
- **FileManager text rebalance** — favourites/trash empty-state titles/subtitles and file-type filter pills downgraded from `text-lg` to `text-base`. `sk-user-menu__item` raised from `text-sm` to `text-base`.

#### Fixed

- **Sticky action bar bleed (`AppDialog`/`SkForm`)** — long forms inside `AppDialog` were leaking scrolling content from underneath the sticky bottom bar; Dialog `content` PT now zeros `padding-bottom` and switches to a flex column layout, `SkForm` adds an `sk-fb--dialog` marker, and `_formbuilder.scss` paints `.sk-fb__actions` opaque, edge-to-edge with `rounded-b-xl` matching `borderRadius.xl`. Scroll content can no longer slide behind the buttons.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/{_base.scss,utilities.css,_formbuilder.scss,_tabs.scss,_menus.scss}
# stubs/resources/js/pages/Profile/Index.vue
# stubs/resources/js/pages/Profile/components/ProfileInfoTab.vue
# stubs/lang/{tr,en}/{sk-avatar.php,sk-profile.php}
php artisan vendor:publish --tag=starter-kit-stubs --force
```

**Behavioural note for `AvatarUpload`:** if your code was passing `:subtitle=""` expecting the default hint to render anyway, it will now hide the hint row instead. Either remove the prop (default i18n applies) or supply a non-empty value.

---

## 2026-05-10 — v13.5.6

### Patch release — axios removed from SystemHealthTab, API envelope compliance, FileManager type fix

`SystemHealthController` was using `response()->json()` instead of the required `to_api()` helper, producing a non-standard JSON body that `useApi` could not parse (no `success` envelope). Fixed. `SystemHealthTab.vue` was importing and calling axios directly, violating the SK hard rule that all API calls must go through the `useApi` composable. Replaced with `useApi({ toast: false })`. A TypeScript type error in `FileManager.vue` is also resolved: `@click` received a `BusyState | null` value; vue-tsc does not narrow `busy` through `v-if` in event handlers; double optional-chaining `busy?.onCancel?.()` resolves both null cases.

#### Fixed

- **`SystemHealthController@run`** — `response()->json()` replaced with `to_api([...], $message)`; return type updated to `ApiResponse|RedirectResponse`. The raw JSON response bypassed the standard `{ success, data, message }` envelope expected by `useApi`, causing the frontend to throw a parse error.
- **`SystemHealthTab.vue`** — `import axios from 'axios'` removed; `useApi({ toast: false })` composable added. `axios.post<...>(url)` replaced with `api.post<...>(url)`. Using axios directly violates the SK hard rule; all API calls must go through `useApi`.
- **`FileManager.vue`** — `@click="busy.onCancel"` changed to `@click="() => busy?.onCancel?.()"`. `busy` is `BusyState | null` and `onCancel` is `(() => void) | null`; vue-tsc does not narrow either through `v-if` in event handlers, so double optional-chaining is required.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# SystemHealthController.php, SystemHealthTab.vue
php artisan vendor:publish --tag=starter-kit-stubs --force
```

---

## 2026-05-08 — v13.5.5

### Patch release — Passport setup fixes, API client scopes removed, Settings tabs, UUID fix

System Health and API client/token management are now embedded as Settings tabs — no more standalone admin pages. A critical bug is fixed: the `scopes` field on OAuth clients never existed in Passport's schema and caused a fatal SQL error on every client create/update. Passport setup now happens fully automatically during `sk:install` and `site:install` (personal access client creation was previously missing). A runtime guard ensures the `api` guard required by Passport is always present even on Laravel 11 where it was removed from the default `auth.php`. Datatable refresh after record creation is now immediate. UUID type fix for the `file_manager_share_revocations` migration, and `InstallCommand` reliability improvements round out the release.

#### Changed

- **System Health moved to Settings tab.** The `/admin/system-health` standalone page is replaced by a Settings tab. The `useAdminMenu.ts` sidebar entry and `system-health` route import are removed. `SystemHealthTab.vue` is now wrapped in a PrimeVue `Card` with title, subtitle, and content slots; the refresh button is inlined in the `#title` slot with `size="small"`.
- **`SystemHealthController@run`** — reverted back to `back()`. The earlier `redirect()->route('admin.system-health.index')` was introduced in v13.5.4 but does not make sense now that System Health lives inside the Settings page.
- **`ApiClientsManageTab.vue` / `ApiTokensManageTab.vue`** — custom `<header>` block and standalone `Button` import replaced with `isCard(true).cardTitle(...).cardSubtitle(...)` on the table builder; the create action is registered via `tableBuilder.create({ label, onClick })` so the `DatatableBuilder` owns the full card layout.

#### Fixed

- **API client `scopes` field removed.** The `scopes` column does not exist on `oauth_clients` in native Passport. The field was dead code across `StoreApiClientRequest`, `UpdateApiClientRequest`, `CreateApiClientAction`, `UpdateApiClientAction`, `ApiClientController`, `ApiClientResource`, `ApiClientForm.vue`, and `ApiClientsManageTab.vue`, and caused `Column not found: 1054 Unknown column 'scopes'` on every create/update. PAT scopes (`$user->createToken($name, $scopes)`) are unaffected — they are stored on `oauth_access_tokens.scopes` and continue to work.
- **`passport:client --personal` now runs automatically during install.** Both `sk:install` and `site:install` now execute `passport:client --personal --provider=users` immediately after `passport:keys`. The missing step caused `LogicException: Unable to determine authentication provider` on token creation in fresh installs.
- **Laravel 11 `api` guard auto-injected at runtime.** `StarterKitServiceProvider::configurePassport()` now checks for `auth.guards.api` and injects `['driver' => 'passport', 'provider' => 'users']` when absent. Laravel 11 removed this guard from the default `auth.php`; Passport's `createToken()` requires it to resolve the user provider.
- **Datatable refreshes immediately after record creation.** `ApiClientsManageTab.vue` and `ApiTokensManageTab.vue` now call `bus.refresh(REFRESH_KEY)` as soon as `onCreated` fires (i.e. the moment the API responds with success), instead of waiting for the user to click "I've saved it" in `OneTimeSecretModal`.
- **`file_manager_share_revocations` migration** — `revoked_by_user_id` column changed from `unsignedBigInteger` to `uuid` to match the UUID primary key on the `users` table. Upgrading from v13.5.3: see the migration note below.
- **`ShareRevocation` model** — `$revoked_by_user_id` PHPDoc type corrected from `int|null` to `string|null`.
- **`InstallCommand`** — `app/Helpers/custom.php` is now auto-created (minimal `<?php` stub) when missing, before `composer dump-autoload`. Absence of this file caused every subsequent artisan call to fail on fresh installs.
- **`DatabaseTestCase`** — in-memory `file_manager_share_revocations` schema updated to use `uuid` for `revoked_by_user_id`, matching the fixed migration.

#### UI

- **`SkDatatable`** — in `isCard` mode the `caption` PT slot now receives `padding: var(--p-card-body-padding) var(--p-card-body-padding) 0`, so title and subtitle align with the standard Card body; the table toolbar and content remain edge-to-edge.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# useAdminMenu.ts, SystemHealthController.php, SystemHealthTab.vue,
# ApiClientController.php, ApiTokenController.php, ApiClientForm.vue,
# ApiClientsManageTab.vue, ApiTokensManageTab.vue, CreateTokenModal.vue,
# OneTimeSecretModal.vue, api-client-route.php, api-token-route.php
php artisan vendor:publish --tag=starter-kit-stubs --force
```

**Passport personal access client** — if you never ran `passport:client --personal` in a previous install, run it once:

```bash
php artisan passport:client --personal --provider=users
```

**Migration note** — if you published `file_manager_share_revocations` in v13.5.3, run a new migration to fix the column type:

```php
Schema::table('file_manager_share_revocations', function (Blueprint $table) {
    $table->dropForeign(['revoked_by_user_id']);
    $table->dropColumn('revoked_by_user_id');
    $table->uuid('revoked_by_user_id')->nullable()->after('revoked_at');
    $table->foreign('revoked_by_user_id')->references('id')->on('users')->nullOnDelete();
});
```

No new permissions or config keys in this release.

---

## 2026-05-07 — v13.5.4

### Patch release — v13.5.3 follow-up: stub fixes, type alignments and CI pipeline reliability

This patch fixes a handful of stub regressions exposed after v13.5.3 (`AdminHeader` `role` typo, missing System Health menu item, `SettingsDefaultsQuery` payload missing `storage_usage`, `SystemHealthController` redirect target, and `trans()` count typing in the Logs pages). The TabBuilder gains a `rose` icon color so the new System Health tab compiles. The CI pipeline is re-ordered so `auto-imports.d.ts` and `components.d.ts` are generated before typecheck, with new guards in `vite.config.ts` for environments without PHP (Wayfinder) or running under Vitest (laravel-vite-plugin HMR check). No new permissions, migrations or config keys.

#### Added

- **TabBuilder — `rose` icon color.** `TabIconColor` accepts `rose`; `_tabs.scss` ships matching `--p-rose-*` light/dark rules. Required by the System Health tab in Settings.

#### Fixed

- **`AdminHeader.vue`** — `page.props.auth?.role` (singular, non-existent) corrected to `roles?.[0]`, matching the `roles: string[]` shared page-prop shape.
- **`useAdminMenu.ts`** — added missing `import systemHealth from '@/routes/system-health'` and a System Health entry (`permission: 'system.health.view'`). The v13.5.3 page was reachable only by URL.
- **`SettingsDefaultsQuery.php`** — added `storage_usage` (`used_bytes`, `quota_bytes`) payload via the `ResolvesMediaModel` trait (`computeStorageUsed()` / `storageQuotaBytes()`). Drives the `StorageQuotaCard` shipped in v13.5.2.
- **`SystemHealthController@run`** — switched from `back()` to `redirect()->route('admin.system-health.index')` (POST → safe GET).
- **`Admin/Logs/{Index,Show}.vue`** — `trans()`/`$t()` `count` replacement values wrapped in `String(...)`; `laravel-vue-i18n` v2.8 strict types reject raw numbers.
- **`tsconfig.json`** — added `@lvntr/components/*` path mapping aligned with the Vite alias so `@lvntr/components/FormBuilder/core` and friends resolve under `vue-tsc`.
- **`env.d.ts`** — typed global `window.turnstile`, plus a `@/routes/*` wildcard module declaration as a fallback when wayfinder hasn't run yet.

#### Build / CI

- **`vite.config.ts`** — `isWayfinderAvailable()` skips the wayfinder plugin when there is no `artisan` (CI / package repo), and an `isVitest` guard skips `laravel-vite-plugin` + `inertia()` during `vitest run` (no more "Vite HMR server in CI" startup error).
- **GitHub Actions Node job re-ordered** — `npm ci` → vendor symlink → route stub generation → build → typecheck → lint (`continue-on-error`) → test. Build now generates `auto-imports.d.ts` / `components.d.ts` before vue-tsc runs.
- **`scripts/ci/generate-route-stubs.mjs`** — node-only CI fallback that writes 16 minimal `@/routes/*` stub files; gitignored so host apps still let wayfinder generate the real ones.
- **Doctor tests** updated to expect the (intentional) English check messages.
- **`.gitignore`** — wayfinder routes, the CI vendor symlink, and Vite build artefacts (`stubs/public/build/`, `stubs/bootstrap/ssr/`) are now ignored.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# AdminHeader.vue, useAdminMenu.ts, SystemHealthController.php, SettingsDefaultsQuery.php,
# Logs/{Index,Show}.vue, env.d.ts, tsconfig.json, vite.config.ts
php artisan vendor:publish --tag=starter-kit-stubs --force

# Re-publish theme files for the new rose tab color
php artisan vendor:publish --tag=starter-kit-theme --force

npm run build
```

If you maintain a custom `tsconfig.json`, add the `@lvntr/components/*` mapping (must come before `@lvntr/*`):

```json
"paths": {
    "@/*": ["resources/js/*"],
    "@lvntr/components/*": [
        "vendor/lvntr/laravel-starter-kit/resources/js/components/Lvntr-Starter-Kit/*"
    ],
    "@lvntr/*": ["vendor/lvntr/laravel-starter-kit/resources/js/*"]
}
```

No new permissions, migrations or config keys.

---

## 2026-05-06 — v13.5.3

### Release — sk:doctor, System Health, Signed Share Link, Bulk Action API, API Client Admin UI, security updates and bug fixes

This release adds the `sk:doctor` health-check command and its System Health admin page, HMAC-signed file share links, a cross-page Bulk Action API for the DatatableBuilder, the Domain Generator v2 opt-in flags, and a full Passport API Client & Token admin UI. It also includes security dependency bumps, event-dispatch fixes for nested folder deletes, Inertia flash response fixes for bulk controllers, and UUID/ULID bulk-action ID support. Existing apps should run the upgrade steps below.

#### Added

- **`sk:doctor` artisan command** — system health check covering 12 control points: PHP extensions, database connection, Redis, Passport keys, storage symlink, writable directories, queue driver, schedule run, mail driver, npm build artifacts, config cache, FileManager disk connection. Machine-readable output via `--json`; selective checks via `--only=database,redis,...`. Exit codes: `0` OK, `1` WARN, `2` FAIL.
- **Admin Panel — System Health page** (`/admin/system-health`) — visualises `sk:doctor` output with per-check status badges and a manual refresh button. Access permission: `system.health.view`.
- **File Manager — Signed Share Link** — HMAC-signed public access URLs. `POST /file-manager/share` creates a link with a TTL; `POST /file-manager/share/revoke` revokes it; `GET /file-manager/share/{media}?expires&signature` validates. Config keys: `file-manager.share.enabled`, `default_ttl_hours` (default 24), `max_ttl_hours` (default 720), `allow_revoke`. Revocations tracked in `file_manager_share_revocations` with a `(media_id, signed_token_hash)` composite unique index. New permissions: `share-media`, `revoke-share-media`.
- **DatatableBuilder — Bulk Action API** — `BulkAction` interface and `BulkActionDispatcher` for cross-page bulk operations. `SkDatatable` supports `select_all_filtered` mode (with filter snapshot) and cross-page selection. Request payload: `{action, ids, select_all_filtered, filter_snapshot}`; response: `{processed, skipped, failed, message}`. Shipped stubs: `BulkDeleteUserAction` (rank-aware) and `BulkDeleteRoleAction` (guards against system roles).
- **Domain Generator v2 (`make:sk-domain`) — opt-in flags** — `--with-policy`, `--with-factory`, `--with-seeder`, `--with-test`, `--with-relations` individually or combined as `--with=policy,factory,test`. `--relations="belongsTo:User,hasMany:Comment,morphTo:commentable"` generates relationship scaffolding automatically. Flag-free invocation preserves v13.5.x behaviour (backward compatible).
- **API Client & Token Admin UI** — admin interface for Passport authorization_code and client_credentials grants and Personal Access Tokens (`/admin/api-clients`, `/admin/api-tokens`). Client secrets and PATs are shown in plaintext only once on creation (`Cache-Control: no-store`); `OneTimeSecretModal` cannot be dismissed. New permissions: `api-clients.create`, `api-clients.read`, `api-clients.update`, `api-clients.delete`, `api-tokens.create`, `api-tokens.read`, `api-tokens.delete`. New validation rule: `HttpsOrLocalhostUrl` (RFC 8252 §8.3 — HTTPS only, localhost HTTP exception).
- **CI Workflow (GitHub Actions)** — PHP test (`pest`), lint (`pint`), and Node 22 build/typecheck/lint jobs. Concurrent runs on the same branch/PR are cancelled via `concurrency: cancel-in-progress`.
- `composer test` (`vendor/bin/pest tests/Feature`) and `composer lint` (`vendor/bin/pint --test`) scripts added for contributors.

#### Fixed

- **`DeleteFolderAction`** — descendant folders were permanently deleted via a query-builder `forceDelete()` call, which skipped Eloquent model events. The `forceDeleted` observer in `FileFolder` (responsible for cleaning up `file_favorites`) never fired for sub-folders, leaving orphan favorite records. Changed to model-level iteration so every `forceDeleted` event is dispatched correctly.
- **`sk:update` — `node_modules/` filtered from stubs scan.** `node_modules/` added to `NEVER_UPDATE_PATHS`; `isNeverUpdate()` check applied to all loops in `updateModifiableFiles`, `addNewFiles`, `migrateHashRegistry` and `updateHashRegistry`. In symlinked (path-repository) setups, `stubs/node_modules/` was leaking into the candidate file list.
- **`sk:doctor` and `sk:update` console output translated to English.** All user-facing messages, tips and table headers in `DoctorCommand`, `UpdateCommand` and the 12 `DoctorCheck` classes are now in English; PHP code comments are unchanged.
- **Bulk action controllers — Inertia flash response.** `UserBulkController` and `RoleBulkController` now return `back()->with('success'/'error', ...)` instead of `ApiResponse` (JSON). The previous JSON response was breaking Inertia's `onSuccess`/`onError` flow and rendering raw JSON on screen; success/error messages now reach `SkFlash`/`useFlash` via `HandleInertiaRequests` flash sharing.
- **Bulk action validation — UUID/ULID/integer ID support.** `BulkActionRequest::rules()` updated: `ids.*` rule changed from `integer` to `string|min:1|max:64`; `prepareForValidation()` casts all incoming IDs to string. The previous `integer` rule caused "The ids.0 field must be an integer" for models using `HasUuids` (User, FileBucket, FileFolder, etc.). The new rule supports integer auto-increment, UUID (36 chars) and ULID (26 chars) primary keys in a single payload schema.

#### Security

- **`dedoc/scramble`** bumped from `^0.13` to `^0.13.22` to address a reported RCE-class advisory (GHSA fixed in v0.13.22).
- **`phpseclib/phpseclib`** updated from `3.0.51` to `3.0.52` to address a high-severity DoS advisory (transitive via `laravel/passport`).
- **Signed Share Link — cross-media token hijack protection.** `(media_id, signed_token_hash)` composite unique index prevents a token from being valid for a different media record.
- **Personal Access Token — privilege escalation guard.** `user_id` body field is rejected; tokens are always minted for the authenticated user.
- **Passport client `confidential` enforcement.** Only `confidential=true` clients can be created via the API Client UI; authorization_code grant requires min:1 redirect URIs with HTTPS. Existing DB records are unaffected.

#### Changed

- **`StarterKitServiceProvider`** — Passport scope and `Gate::before` registrations consolidated to a single source; duplicate registrations removed from the `AppServiceProvider` stub.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit

# Publish and run new migrations
php artisan vendor:publish --tag=starter-kit-migrations
php artisan migrate

# Publish updated file-manager config (new share.* keys)
php artisan vendor:publish --tag=starter-kit-config --force

# Publish new admin page and controller stubs
# WARNING: customised stubs will be overridden — diff first
php artisan vendor:publish --tag=starter-kit-stubs --force

# Add new permissions and reset permission cache
php artisan db:seed --class=PermissionResourcesSeeder
php artisan permission:cache-reset
```

**New permissions:** `system.health.view`, `share-media`, `revoke-share-media`, `api-clients.create`, `api-clients.read`, `api-clients.update`, `api-clients.delete`, `api-tokens.create`, `api-tokens.read`, `api-tokens.delete`.

**Behaviour changes:**

- `confidential=false` authorization_code Passport clients can no longer be created via the UI. Existing DB records are unaffected.
- Personal Access Token minting: `user_id` body field removed; to mint a PAT for another user use an artisan command or a custom action.
- If your `AppServiceProvider` stub has duplicate Passport scope / `Gate::before` blocks, remove them — `StarterKitServiceProvider` handles this now.

---

## 2026-05-06 -v.13.5.2

### Patch release — Settings security consolidation, FileManager restore fix and i18n improvements

Settings now consolidates Auth and Turnstile into a single **Security** tab and adds a Storage Quota card that visualises disk usage. The File Manager trash restore bug is fixed: the trash view now only shows root-level deleted items so single and bulk restore always succeed without the "parent in trash" error. All File Manager component text sizes are standardised to `text-lg` (14 px), confirmation dialogs are translated via `trans()`, and filter pill labels are internationalised. Existing apps should run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm run build`.

#### Added

- **`SecurityTab.vue`** consolidates Authentication and Cloudflare Turnstile settings into one tab, replacing the removed `AuthTab.vue` and `TurnstileTab.vue` stubs.
- **`StorageQuotaCard.vue`** displays disk-wide storage quota usage as a progress bar in the Settings panel.
- **`SettingsDefaultsQuery`** now includes `storage_usage` (`used_bytes`, `quota_bytes`) in the Inertia payload.
- **i18n keys** added in `sk-setting` (security/storage section labels), `sk-file-manager` (filter pill labels: `all`, `image`, `video`, `pdf`, `audio`, `archive`) and `sk-common` (confirmation dialog strings).
- **`config('file-manager.settings.enable_trash')`** — new config key that controls soft-delete vs hard-delete for the entire FileManager. `true` (default) sends deleted files and folders to Trash; `false` permanently deletes immediately. Both `DeleteFileAction` and `DeleteFolderAction` read the config at delete time. The value is shared automatically via Inertia (`fileManagerSettings.enable_trash`) so the Vue component falls back to the config without needing the `:enable-trash` prop — the prop can still be passed to override per-instance.

#### Fixed

- **Trash restore bug.** `TrashContentsQuery` now only returns root-level trashed items. Items whose parent folder was also in trash were listed as independent items, making both single and bulk restore fail with "Cannot restore: the parent folder is also in trash". Root-level filtering ensures restore operations always start from the top of the tree.

#### Changed

- **FileManager minimum text size** standardised to `text-lg` (14 px) across `FileManager.vue`, `FileGrid.vue`, `FileManagerSidebar.vue` and `FileManagerStats.vue`.
- **`useConfirm` composable** confirmation strings moved to `trans()` calls using new `sk-common` translation keys.
- **`Admin/Files/Index.vue`** simplified — unnecessary wrapper `<div>` removed.
- **File Manager tab** — Video/Audio upload toggles now use the same checkbox-grid layout as Images.

#### Removed

- **`AuthTab.vue`** and **`TurnstileTab.vue`** stubs — content merged into `SecurityTab.vue`. `sk:update` cleans them up automatically via `DEPRECATED_PATHS`.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit
php artisan sk:update
npm run build
```

---

## 2026-05-05 -v.13.5.1

### Patch release — NPM exports fix, sk:publish improvements, storage quota and upload validation

NPM package `main` and `exports` paths are corrected to match the actual file structure. Individual `sk:publish` tags now work correctly. Storage quota is configurable in GB from Admin Settings > File Manager, and upload requests now return a localised error when the quota is exceeded. Existing apps should run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build`.

#### Fixed

- **NPM package `main` and `exports` paths** now reflect the actual file structure (`resources/js/components/Lvntr-Starter-Kit/...`). FileManager export added.
- **`sk:publish` individual tags** (`form`, `datatable`, `tabs`, `skeleton`, `ui`) had broken source paths referencing the old structure; corrected with the `Lvntr-Starter-Kit/` segment.
- **`vendor:publish --tag=starter-kit-components` nested path bug** resolved. Was producing `resources/js/components/Lvntr-Starter-Kit/Lvntr-Starter-Kit/...`; now publishes directly to `resources/js/components/Lvntr-Starter-Kit/`.
- **`vendor:publish --tag=starter-kit-file-manager-components`** is now active. Source path pointed to the old directory name (`file-manager`); realigned with the actual directory (`Lvntr-Starter-Kit/FileManager`).
- **`index.ts` barrel** — 9 missing component exports added: `EditorInput`, `EditorImagePicker`, `EditorColorPalette`, `TranslatableInput`, `ImageLightbox`, `FilePreviewModal`, `ToggleFeatureCard`, `MimePickerField`, `SkTag`.

#### Added

- **`sk:publish --tag=filemanager`** — new tag for publishing the FileManager UI separately.
- **`sk:install --without-ai-skill`** — skip AI skill publishing (`stubs/.claude/skills/`) for consumers that don't use the Claude Code skill bundle.
- **`.gitattributes`** — Composer archive now excludes `tests/`, `docs/`, `.github/`, `plan-docs/`, `package-audit-notes/` and other development-only paths; smaller archive size.
- **`.npmignore`** — NPM package excludes `__tests__/`, `*.spec.*`, `*.test.*` (root and subdirectories; compatible with npm 11 behavior).
- **Disk-wide storage quota (`storage_quota_gb`).** A single quota in GB can be set from Admin Settings > File Manager (default 10 GB). Covers all contexts (`user`, `global`, custom morph map entries) including trash (`withTrashed`).
- **Upload quota validation.** `UploadFileRequest::withValidator()` adds a quota check; when exceeded the request returns HTTP 422 with a localised `errors.quota_exceeded` message.

#### Removed

- **Duplicate domain commands removed from stubs:** `EnvSyncCommand`, `MakeDomainCommand`, `RemoveDomainCommand`. They continue to run from vendor as the single source. `sk:update` cleans them up in existing consumer projects via `DEPRECATED_PATHS`.
- **`App\Http\Responses\ApiResponse.php` stub removed.** A `StarterKitServiceProvider` alias guard maps `App\Http\Responses\ApiResponse` → `Lvntr\StarterKit\Http\Responses\ApiResponse` once the consumer file is deleted; existing `use App\Http\Responses\ApiResponse;` imports continue to work unchanged.
- **`Lvntr\StarterKit\Enums\PermissionEnum` removed from vendor.** Canonical location is `App\Enums\PermissionEnum` (under stubs). No vendor references existed (confirmed by grep). If your code imports this namespace directly, update it to `App\Enums\PermissionEnum`.

#### Changed

- **`sk:publish` is now the primary publish command.** Granular interactive flow with namespace rewrite support. `vendor:publish --tag=starter-kit-*` is kept for backward compatibility but `sk:publish` is now the recommended path in install and command docs.
- **`ResolvesMediaModel::computeStorageUsed()` signature changed (internal trait).** No longer accepts a parameter; returns the disk-wide total via `Media::withTrashed()->sum('size')`. Previous behavior was per-context (`model_type` + `model_id` filtered). If your app extends this trait and calls `computeStorageUsed($context)`, remove the argument.
- **`FolderContentsQuery`, `FavoritesContentsQuery`, `TrashContentsQuery`** — `stats.storage_quota` field added (bytes).
- **`FileManager.vue`** — hardcoded `STORAGE_QUOTA_BYTES` constant removed; `quotaBytes` is now computed from `stats.storage_quota`. The quota sidebar hides (`v-if="quotaBytes > 0"`) when quota is zero or undefined.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit
php artisan sk:update
```

`sk:update` output will list 4 paths under "Removed" — this is expected.

---

## 2026-05-05 -v.13.5.0

### Major release — Vendor-first runtime and frontend UI lib

The starter kit runtime moves entirely to vendor. FileManager backend, shared base classes, traits, helpers, middleware, ApiResponse and the route loader now live under `vendor/lvntr/laravel-starter-kit/src/` with the `Lvntr\StarterKit\` namespace. The frontend component library (`DatatableBuilder`, `FormBuilder`, `TabBuilder`, `FileManager`, `Skeleton`, `ui`) is also now canonical inside the package, consumed by the app via vendor symlink. Existing apps only need `composer update`; no file changes, no route names break, and `php artisan migrate` returns "Nothing to migrate". Frontend migration to vendor is fully opt-in. Upgrade instructions: [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade).

#### Changed

- **Vendor-first architecture.** Package runtime no longer flows through stubs — it runs directly from `vendor/`. `sk:install` publishes skeleton files (auth, layout, user/role/settings domain, config); it no longer copies FileManager and Shared layers into `app/`.
- **`sk:update` simplified.** No file copying for vendor runtime; `composer update` is enough. Hash-tracked stubs (auth/layout/user/role/settings) retain their existing diff/notify behaviour.
- **Frontend UI lib relocated.** `resources/js/components/Lvntr-Starter-Kit/{DatatableBuilder,FormBuilder,TabBuilder,FileManager,Skeleton,ui,index.ts}` is now the canonical package location. Apps consume it via vendor symlink.
- **`stubs/vite.config.ts` alias updated.** New installs get `@lvntr/components` pointing to `vendor/lvntr/laravel-starter-kit/resources/js/components/Lvntr-Starter-Kit` with `preserveSymlinks: true` and vendor path in the `Components({ dirs })` array.
- **`FileManagerAction` abstract base + `ResolvesMediaModel` trait.** Resolves the Media model via `media-library.media_model` config; app-specific `App\Models\Media` overrides (e.g. with SoftDeletes) work without changes.
- **`Http/Requests/FileManager/UploadFileRequest`.** Protected methods — overridable on the app side (e.g. Settings integration).

#### Added

- **`src/Domain/FileManager/`** — Actions, DTOs, Queries, Services, Support under `Lvntr\StarterKit\Domain\FileManager\` in vendor.
- **`src/Domain/Shared/`** — BaseAction, BaseDTO, ActionPipeline, PipeableAction under `Lvntr\StarterKit\Domain\Shared\` in vendor.
- **`src/Traits/`** — HasActivityLogging, HasMediaCollections under `Lvntr\StarterKit\Traits\` in vendor.
- **`src/sk-helpers.php`** — `to_api()`, `definition()`, `definitionLabel()`, `sk_locale_keys()`, `sk_default_locale()`, `format_date()` with `function_exists` guards in vendor.
- **`src/Http/Responses/ApiResponse.php`** — `{success, status, message, data, errors?}` envelope preserved, moved to vendor.
- **`src/Http/Middleware/`** — CheckResourcePermission, SecurityHeaders under `Lvntr\StarterKit\Http\Middleware\` in vendor.
- **`src/Http/Controllers/FileManagerController.php`** and **`src/Http/Requests/FileManager/*`** — in vendor.
- **`src/Console/Commands/PurgeFileManagerTrashCommand.php`** — `file-manager:purge-trash` signature preserved.
- **`src/Exceptions/`** — ApiException, ApiExceptionHandler in vendor.
- **`src/Facades/FileManager.php`** — single-line route mount via `FileManager::routes()`.
- **`src/routes/file-manager.php`** — 19 routes, all names preserved exactly. Consumer's own route file takes precedence.
- **`database/migrations/`** — 3 FileManager migrations, filenames and content preserved exactly.
- **`config/file-manager.php`** — `models.*` and `settings.*` keys added.

#### Deprecated

- **`sk:sync` (PackageSyncCommand).** No longer needed with the Composer path-repository symlink workflow. The `--force` escape hatch is preserved.

#### Upgrade

```bash
composer update lvntr/laravel-starter-kit
php artisan migrate
```

Existing `app/Domain/FileManager/`, `app/Domain/Shared/`, `app/Traits/`, `app/Helpers/sk-helpers.php` and related files stay in place and continue to work. Migrating them to the vendor versions is completely optional. Frontend cleanup (switching the Vite alias to vendor path and removing the app-side copy) is also opt-in. See [UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) for both guides.

---

## 2026-05-04 -v.13.4.10

### Minor release — Translatable FormBuilder fields and Sample Contents reference module

FormBuilder now supports multi-language text fields out of the box. Three new builders — `FB.translatableText()`, `FB.translatableTextarea()` and `FB.translatableEditor()` — render one input per active language and submit JSON-ready locale maps for Spatie Translatable models. The release also adds backend helpers for validation, datatable search/sort and resource output, plus a shipped Sample Contents module that demonstrates the full pattern end to end. Existing apps should run `composer update lvntr/laravel-starter-kit && php artisan sk:update && php artisan migrate && npm install && npm run build`.

#### Added

- **Translatable FormBuilder fields.** `FB.translatableText()`, `FB.translatableTextarea()` and `FB.translatableEditor()` render per-locale inputs driven by the active language list. They support locale filtering (`onlyLocales`, `exceptLocales`), inline or tabbed layouts, and locale label styles (`badge`, `name`, `flag`).
- **Backend translatable helpers.** `HasTranslatableRules` generates FormRequest rules and validation labels per locale. `TranslatableQueryHelpers` provides JSON-column search, locale-aware sorting and `resourceShape()` output for datatables and edit forms.
- **Locale helper functions.** `sk_locale_keys()` returns active locale codes in order, while `sk_default_locale()` resolves the primary locale with a fallback to `app.fallback_locale`.
- **Sample Contents module.** A complete admin CRUD reference ships with a translatable model, migration, factory, domain actions/events/listeners, FormRequests, resource, datatable query, Vue pages and menu/permission entries.
- **Documentation.** New [Translatable Fields](https://starter-kit.lvntr.dev/docs/translatable-fields) and [Çevrilebilir Alanlar](https://starter-kit.lvntr.dev/docs/translatable-fields) guides document the full backend/frontend flow, migration strategy and Sample Contents reference implementation.
- **Package dependency.** `spatie/laravel-translatable` is now part of the application dependency set for JSON-backed translated attributes.

#### Improved

- **FormBuilder docs.** The FormBuilder guide now lists the translatable builders and links to the dedicated guide.
- **File Manager no-trash mode docs.** The File Manager guide now clarifies that `enableTrash=false` routes single and bulk delete operations to permanent deletion, including `force_delete=true` for bulk deletion.
- **Lvntr builder skill docs.** Project agent guidance for FormBuilder now includes the translatable field builders so future generated admin forms use the supported API.

#### Upgrade

Run migrations and rebuild frontend assets after updating:

```bash
composer update lvntr/laravel-starter-kit
php artisan sk:update
php artisan migrate
npm install
npm run build
```

Apps that already have custom language/settings handling should verify the active language list used by `general.languages`. Existing plain string columns are not migrated automatically; convert them to JSON with a staged migration before switching a model attribute to Spatie `HasTranslations`.

## 2026-05-02 -v.13.4.9

### Minor release — File Manager favorites, trash, restore, permanent delete, copy and rename

File Manager now ships the feature set that was previously visible as placeholders in v13.4.8. Favorites and Trash are real quick-access views, folder/file tiles can be starred, deleted items move to trash by default, trash items can be restored or permanently deleted, and the trash view has an **Empty Trash** action. Files can also be duplicated and renamed from the context menu. This release adds two migrations (`file_favorites` and soft deletes on `media`), new backend actions/queries/requests, new File Manager routes, extended EN/TR language keys, and a daily `file-manager:purge-trash` scheduled command. Existing apps should run `composer update lvntr/laravel-starter-kit && php artisan sk:update && php artisan migrate && npm install && npm run build`.

#### Added

- **Favorites.** New `file_favorites` table and `FileFavorite` model store starred folders/files per owner context. `FavoritesContentsQuery` powers the sidebar **Favorites** view, `FolderContentsQuery` now annotates items with `is_favorited`, and the grid/context menus expose Add/Remove Favorite actions.
- **Trash and restore flow.** Files and folders now soft-delete into Trash when `enableTrash` is true. `TrashContentsQuery` powers the **Trash** quick view, deleted tiles show their deleted timestamp, and trash context menus switch to Restore / Permanently Delete actions.
- **Empty Trash.** `EmptyTrashAction` and `DELETE /file-manager/trash/empty` permanently delete all trashed File Manager items for the current context; files are removed before folders and folders are deleted post-order so nested trees clear safely.
- **File copy and file rename.** Files can be duplicated with copy-safe names such as `photo (copy).jpg` / `photo (copy 2).jpg`, and renamed through the shipped dialog and `PATCH /file-manager/files/{media}` endpoint.
- **Trash purge command.** `php artisan file-manager:purge-trash --days=7` permanently deletes File Manager trash older than the selected age. It is scheduled daily from `routes/console.php`.
- **`enableTrash` prop.** `FileManager` defaults to soft-delete behaviour; setting `:enable-trash="false"` restores immediate permanent deletion semantics for projects that do not want a trash workflow.

#### Security

- **Context validation centralised.** `FileManagerContextRequest` now validates and resolves the current File Manager context consistently across virtual views and item mutations, closing gaps where favorites/trash endpoints could drift from the regular folder-content checks.
- **Soft-delete scope hardening.** Restore, permanent-delete, copy, rename and favorite actions now explicitly scope items to the current context and use `withTrashed()` / `onlyTrashed()` where needed, preventing cross-context access and ensuring trashed items are found only in the intended paths.
- **Folder restore cascade guardrails.** Restoring a trashed folder restores its descendant folders and File Manager media in a transaction. If its parent is still trashed, restore is refused until the parent is restored first; if the parent was permanently deleted, the item is restored to root to avoid an orphan.

#### Fixed

- **Bulk force delete can now find trashed items.** `BulkDeleteAction` uses `withTrashed()` when `force=true`, so permanent deletion from the Trash view no longer misses items that are already soft-deleted.
- **Language key collision fixed.** `labels.details` is now the details-section array, while the action label moved to `labels.details_action`; this prevents the file details dialog labels from being overwritten by the context-menu action string.
- **Collection scoping tightened.** Trash purge and permanent delete affect File Manager media (`collection_name = files`) without touching avatars, logos, editor uploads or other MediaLibrary collections.

#### Upgrade

Run migrations after update:

```bash
composer update lvntr/laravel-starter-kit
php artisan sk:update
php artisan migrate
npm install
npm run build
```

No breaking API response change. Apps that customised File Manager stubs should compare their local files with the shipped updates before using `sk:update --force`, especially `FileManager.vue`, `useFileManager.ts`, `FileGrid.vue`, `FileManagerController.php`, `routes/web/file-manager-route.php`, `lang/{en,tr}/sk-file-manager.php`, the new requests/actions/queries, and the two migrations.

## 2026-04-30 -v.13.4.8

### Minor release — File Manager UX overhaul (sidebar + stats + details + search)

File Manager UX overhaul — the same backend, same routes, same media table; a new shell. The single-column grid is replaced by a sidebar + main-column layout, with three new shipped components (`FileManagerSidebar`, `FileDetailsDialog`, `FileManagerStats`), a top-bar search box that filters the current folder client-side, and an expanded right-click menu with new entries (Open in new tab, Preview, Share, Copy, Rename, Add to Favorites, Details). All previously documented behaviour — uploads, drag-and-drop move, bulk delete, image lightbox, preview dialog, custom contexts, settings, permissions — works exactly as before; the change is purely shipped frontend (`FileManager.vue` + the three new components + `types.ts` + `lang/{en,tr}/sk-file-manager.php`). No new composer or npm dependency, no migration, no config, no permission entry. Existing consumer apps run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build` to pick up the patches; no breaking change.

#### Added

- **`FileManagerSidebar.vue` — left-rail with circular storage-usage ring, quick-access list, folder tree, "New Folder" button.** The storage ring uses an SVG circle with a `circumference - dashOffset` fill and a colour-band threshold (primary < 70 %, amber 70–90 %, rose ≥ 90 %); used bytes come from `fm.contents.stats.total_size`, the quota is currently a sane visual default of 10 GB until a backend setting is wired. The folder tree reuses the same `fm.tree` data the move modal already loads. Quick-access targets: **All Files** resets to the root sorted by name asc, **Recently Uploaded** resets to the root sorted by date desc, **Favorites** and **Trash** show the new `coming_soon` toast as placeholders for an upcoming feature.

- **`FileDetailsDialog.vue` — file details modal showing Name, Type, Size, Uploaded, Folder, and (for images) Dimensions.** Image dimensions are loaded async — the dialog kicks off a hidden `new Image()` against `file.url` and pushes `naturalWidth × naturalHeight` into the rendered row when `onload` fires. The dialog ships with a "Download" footer button that reuses the same `downloadFile` handler as the right-click menu, so the action surfaces stay aligned. Wired up from the new "Details" entry in the file context menu.

- **`FileManagerStats.vue` — top-bar stats widget (Total Files, Total Size, Folder Count, Favorites, Last Upload).** Renders a horizontal row of icon-tinted cards (`bg-{colour}-100` in light, `bg-{colour}-900/40` in dark). Folder count traverses the full nested tree (`flattenTree(fm.tree.value)`); last-upload reflects the most-recent `created_at` in the current folder, formatted as "Just now / X min / X hr / X d / locale-date" via the new `stats.time_*` keys.

- **Top-bar search.** `IconField` + `InputText` strip above the body filters `fm.contents.folders` and `fm.contents.files` by `name` / `file_name` (case-insensitive `includes`), surfaced via the new `filteredFolders` / `filteredFiles` computeds. Filter is local to the rendered folder; navigating clears it implicitly the next time `fm.loadContents()` runs.

- **Expanded file context menu — Open / Preview / Download / Share / Move / Copy / Rename / Add to Favorites / Details / Delete.** "Open" now opens the file in a new tab (`window.open(file.url, '_blank', 'noopener,noreferrer')`); "Preview" keeps the existing lightbox / dialog flow; "Share" copies the absolute file URL to the clipboard (`navigator.clipboard.writeText(...)`) with a localised "Link copied" toast on success and the `coming_soon` toast on permission refusal; "Details" opens the new dialog; "Copy", "Rename", "Add to Favorites" are placeholders for upcoming features. The destructive Delete row gets a new `fm-menu-danger` class so it can be styled distinctly.

- **Folder context menu — adds "Add to Favorites" (placeholder) before Delete.** Same `coming_soon` toast pattern as the file-menu placeholders.

- **`types.ts` — adds `ViewMode = 'grid' | 'list'` and `QuickView = 'all' | 'recent' | 'favorites' | 'trash'`.** `ViewMode` is reserved for an upcoming list-view renderer (currently grid-only); `QuickView` is consumed by the sidebar quick-access flow. Existing exports unchanged.

- **`lang/{en,tr}/sk-file-manager.php` — new keys.** Top-level: `link_copied`, `coming_soon`. Labels: `upload_new`, `preview`, `share`, `copy`, `add_to_favorites`, `details`, `search_placeholder`, `view_grid`, `view_list`, `files_section`, `folders_section`, `no_results`. New nested groups: `labels.sidebar.*`, `labels.stats.*`, `labels.details.*`.

#### Removed

- **Legacy header back-button + sort dropdown removed from `FileManager.vue`.** The previous shell had a `←` back button + `Select` dropdown for sort key + a direction-toggle button in the header; navigation now happens through the sidebar (folder tree + breadcrumb) and sorting is driven by the quick-access flow ("Recently Uploaded" = `setSort('date', 'desc')`). The `useFileManager` composable still exposes `setSort` / `toggleSortDirection` for direct callers.

#### Upgrade

No breaking changes. Existing consumer apps run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build` — `sk:update` will pick up the new shipped files and the extended language keys. The data shape on the wire is unchanged; backend is unchanged.

## 2026-04-26 -v.13.4.7

### Patch release — silence duplicate Link extension warning in `EditorInput`

Single-fix patch — silences the `Duplicate extension names found: ['link']` warning Tiptap printed when `EditorInput` booted. Tiptap v3's `@tiptap/starter-kit` started bundling the Link extension by default, but our editor was still pushing `@tiptap/extension-link` through the optional `props.links` branch with our own `openOnClick: false, autolink: true` config — so two `link` registrations went into the same editor. The fix is a single config flag on the StarterKit call (`link: false`) so the bundled copy is disabled and our manual-push branch stays the single source of truth. Behaviour is identical for both `props.links === false` (no Link at all) and `props.links === true` (manual-push only); only the console noise is gone. Existing consumer apps run `composer update lvntr/laravel-starter-kit && php artisan sk:update` — no migration, no config, no breaking change.

#### Fixed

- **`EditorInput.vue` — duplicate Link extension warning silenced.** Tiptap v3's `@tiptap/starter-kit` bundles the Link extension by default; the editor was also pushing `@tiptap/extension-link` through the optional `props.links` branch, so the editor booted with `Duplicate extension names found: ['link']` in the console. `StarterKit.configure({ heading: { levels: [2, 3, 4] }, link: false })` disables the bundled copy so our manual-push branch (with our own `openOnClick: false, autolink: true` config) is the only source. `props.links === false` cleanly removes Link entirely; `props.links === true` runs only the manual-push branch — same effective behaviour, no warning.

#### Upgrade

No breaking changes. `composer update lvntr/laravel-starter-kit && php artisan sk:update` picks up the patch — the fix ships in the same shipped Vue file `sk:update` already tracks; no extra step needed.

## 2026-04-26 -v.13.4.6

### Patch release — Vite optional-peer-dep stub + `sk:update` package.json merge

Two related build/upgrade fixes that surface when consumers upgrade from a pre-`EditorInput` version of the kit (any 13.4.0 or earlier install) to 13.4.2+. The package's `package.json` no longer declares its `@tiptap/*` set as `peerDependencies` + `peerDependenciesMeta.optional` — those declarations were tripping Vite's optional-peer-dep stub fallback (`__vite-optional-peer-dep:@tiptap/extension-table:@lvntr/starter-kit:false`) when resolving from `vendor/lvntr/laravel-starter-kit/`, even on consumer apps that already had the deps installed at the project root. The result was `"Table" is not exported by …` at build time and `does not provide an export named 'BubbleMenu'` at runtime — both produced by Vite's stub module (`export default {}; throw …`) instead of the real package. And `sk:update` now mirrors `sk:install`'s `mergePackageJson()` step so the new `@tiptap/*` set lands in the consumer's `package.json` automatically on upgrade — previously only fresh installs picked them up, leaving every consumer who upgraded from `<13.4.2` to copy 16 dependency entries by hand. Stub-version-wins for shared keys, user extras preserved, idempotent on re-runs.

#### Fixed

- **Package `package.json` — dropped `peerDependencies` + `peerDependenciesMeta` for the `@tiptap/*` set.** The package is composer-distributed (not on npm) so the peer-dep declarations had no effect on `npm install`; their only practical impact was Vite's `tryNodeResolve` fallback. When a bare-import (`import { Table } from '@tiptap/extension-table'`) couldn't be resolved through the normal `node_modules` walk-up — easy to trigger when the package is in `vendor/`, not `node_modules/` — Vite checked the importer's nearest `package.json`, found the dep listed as an optional peer, and returned `__vite-optional-peer-dep:<dep>:<parent>:<isRequire>` instead of erroring. The stub is loaded as `export default {}; throw new Error("Could not resolve …")` — no named exports, hence the misleading `"Table" is not exported by …` build error and the runtime `does not provide an export named 'BubbleMenu'` for the `@tiptap/vue-3/menus` subpath. Removing the declarations restores plain `node_modules` resolution which walks up to the project root and finds the real packages.

- **`sk:update` now merges `stubs/package.json` into the consumer's `package.json`.** `UpdateCommand` previously only touched files under `app/`, `config/`, `resources/` and `routes/` — never the project's `package.json`. So the 16 `@tiptap/*` entries that 13.4.2 added to the stub never reached consumers who upgraded via `composer update lvntr/laravel-starter-kit && php artisan sk:update`. The new step (4c in `handle()`) mirrors `InstallCommand::mergePackageJson()`: stub keys win at the root, `array_merge`-d `dependencies`/`devDependencies` (sorted), user extras preserved, only writes when the rendered JSON actually differs (so re-runs are no-ops). The summary surfaces the change as `package.json (merged stub dependencies — run npm install)` so the user knows to run `npm install` afterwards.

#### Upgrade

No breaking changes. Existing consumer apps run `composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build` — `sk:update` will now sync the missing `@tiptap/*` entries into your `package.json` and Vite will resolve them against the real packages instead of the stubs.

## 2026-04-26 -v.13.4.5

### Patch release — code-review sweep (API hierarchy + role-data + 2FA loading + permission directive + i18n)

Closes a small batch of findings from a follow-up code review of the v13.4.x surface. Two security/info-disclosure fixes (API user list now applies the same role-hierarchy filter the admin panel does, and the role JSON `data` endpoint now runs the same `CanManageRoleQuery` guard the `edit`/`destroy` actions do), one UX fix (the 2FA enable/disable buttons now reset their loading state on failure paths, not just the happy path), one latent-bug fix (the `v-role` directive read the wrong Inertia shared-prop key and silently always returned `false`), and one i18n cleanup (the `useApi` composable's error toasts and synthesized envelope messages now flow through `sk-message.*` keys instead of hardcoded Turkish strings). All changes are additive on the wire — same response shape, same status codes, same UI. Three regression tests guard the two security fixes. Existing consumer apps pick the patches up via `php artisan sk:update`; no migration, no config, no breaking change.

#### Security

- **`Api/UserController::index` now delegates to `UserDatatableQuery` — same role-hierarchy filter as the admin panel.** Previously the API used a bespoke `DatatableQueryBuilder` chain that skipped the `whereDoesntHave('roles', sort_order < me)` clause `UserDatatableQuery` enforces. Result: a non-`system_admin` API consumer holding `users.read` could `GET /api/v1/users` and see every higher-rank user — including `system_admin` accounts — whereas the admin UI hid them. The controller now method-injects `UserDatatableQuery` and returns its `response($request->user())` directly. The query's allowlists were extended with the `first_name`, `last_name`, `email`, `status`, `id`, `created_at` sortable keys (previously API-only) so the wire contract for legitimate API callers is unchanged. Covered by the new `tests/Feature/Api/UserTest.php` "hides higher-rank users from non-system_admin api callers" regression test.

- **`Admin/RoleController::data` now runs `CanManageRoleQuery` before returning role JSON.** `data()` is the JSON sibling of `edit()` (the admin role form prefetches it via `useApi().get('/admin/roles/{role}/data')`). `edit()` and `destroy()` already gated through `CanManageRoleQuery::check()` to enforce the role hierarchy; `data()` did not — so a lower-rank admin could read the full permission set of a higher-rank role over JSON, even though the form they would render the data into is hierarchy-aware. The check is now inlined at the top of `data()` (`abort(403)` on mismatch), mirroring `edit()`. Covered by two new `tests/Feature/Admin/RoleManagementTest.php` regression tests ("forbids non-system_admin from reading higher-rank role data" + the positive sibling for same/lower rank).

#### Fixed

- **2FA enable/disable buttons no longer get stuck on error.** `Profile/components/TwoFactorTab.vue` set `twoFactorProcessing = true` before calling Fortify, but only reset it on the success branch. An axios 4xx/5xx (typical: an expired session, password-confirm timing out) or an Inertia `router.reload` error left the button spinner stuck until full page reload. Both `enableTwoFactor()` and `disableTwoFactor()` now reset the flag in a `finally` block, so any failure surfaces as a re-clickable button + a toast (rather than a frozen UI).

- **`v-role` directive now reads the correct Inertia shared-prop key.** `resources/js/plugins/permission.ts` checked `auth.roles`, but `HandleInertiaRequests` shares the user role names under `auth.role_names`. The directive silently always evaluated to `false` — `<div v-role="'system_admin'">` markup was never visible regardless of the actor's role. The plugin now reads `auth.role_names`. The duplicate `useCan` export inside the plugin file (which read the same wrong key) was removed too — the canonical `useCan()` lives at `@/composables/useCan` and was already correct, so application code was unaffected. The plugin file now exports only the `PermissionPlugin` (registers `v-can` + `v-role`).

- **`useApi` composable error messages flow through `sk-message.*` i18n keys.** `resources/js/composables/useApi.ts` had three hardcoded Turkish error strings (synthesized envelope on non-JSON response, network-failure toast detail, toast `summary`). Replaced with `trans('sk-message.invalid_response')`, `trans('sk-message.request_failed', { status })`, `trans('sk-message.network_error')`, `trans('sk-message.error_summary')`. The four new keys are added to both `lang/en/sk-message.php` and `lang/tr/sk-message.php`. EN-locale users no longer see Turkish copy when an API call fails outside the normal envelope path.

#### New

- **Regression tests for the two security fixes.** `tests/Feature/Api/UserTest.php` gains the `hides higher-rank users from non-system_admin api callers` test — seeds the role hierarchy via `RoleEnum` index, mirrors `users.read` + `admin` role into the `api` guard (Spatie's `Guard::getDefaultName()` switches to `api` under `Passport::actingAs`), assigns both web + api versions of the role to an admin user, and asserts the response excludes the higher-rank `system_admin` peer + the acting `system_admin` user but includes the same-rank admin peer. `tests/Feature/Admin/RoleManagementTest.php` gains two: `forbids non-system_admin from reading higher-rank role data` (admin gets 403 on `/admin/roles/{system_admin}/data`) and `allows non-system_admin to read lower-rank role data` (admin gets 200 on `/admin/roles/{user}/data`).

## 2026-04-25 -v.13.4.4

### Patch release — system-admin log viewer (`/logs`)

Adds a maintainer-only admin section for browsing, searching and deleting Laravel log files in `storage/logs/`. Self-contained — no new composer or npm dependency, no migration, no permission entry. Visible only to `system_admin` users; everyone else still sees the same panel as before. All additive.

#### Added

- **`/logs` admin section — system-admin-only log viewer.** A new sidebar item under "System" lists the contents of `storage/logs/` in an `SkDatatable` (filename, channel type, size, modified time, active flag), and a per-file viewer page applies structured filters (level, date range, keyword) over a cursor-paginated entry stream. Single + bulk delete are wired through the same endpoint with partial-success semantics — active files (today's daily log, anything written within the last 5 seconds) are refused per-file and reported back in `failed[]`, the rest go through. Each delete batch dispatches a `LogFilesDeleted` event; the new `LogActivityForLogFilesDeleted` listener writes a `spatie/activitylog` entry under `log_name = system`, so deletions surface automatically in **Admin → Activity Logs**.

- **`app/Domain/Logs/` bounded context.** Four DTOs (`LogFileDTO`, `LogEntryDTO`, `LogEntryFilterDTO`, `DeleteLogFilesDTO`), two queries (`LogFileQuery` for the file list, `LogEntryQuery` for streaming entries), one action (`DeleteLogFilesAction`), one event/listener pair, and a stateless `LaravelLogParser` service. `LogEntryQuery::paginate()` reads the file with `fopen('rb')` + 64KB-capped `fgets()` and a byte-offset cursor, so memory stays bounded regardless of file size; multi-line stack traces are kept attached to the entry that opened them, and any line that appears before the first Laravel-format header (or in a file with no headers at all) surfaces as a single raw `LogEntryDTO` (`is_raw = true`, gray chip, hidden timestamp) so file content is never silently dropped. Raw entries are filtered out the moment any structured filter (level / from / to / keyword) is applied.

- **`logs.*` named route group.** `routes/web/log-route.php` ships five routes — `index`, `dtApi`, `show`, `entries`, `destroy` — wrapped in `role:system_admin`. The `{filename}` parameter constraint (`[A-Za-z0-9._-]+\.log`) is enforced on both `show` and `entries`, so path traversal and non-`.log` requests never reach the controller. The file is added to the `$routesWithoutPermissionMiddleware` allowlist in `routes/web.php` because the section is role-gated, not permission-gated.

- **`lang/{en,tr}/sk-log.php` translation file.** All UI copy (filter labels, empty states, delete confirmations, failure reason codes) lives behind the `sk-log.*` namespace in both languages. The new `sk-menu.logs` key labels the sidebar entry.

#### Security

- **Path-traversal guardrail at three layers.** The safe filename regex `^[A-Za-z0-9._-]+\.log$` is enforced in (1) the route parameter constraint, (2) `DeleteLogFilesRequest` rules, and (3) `DeleteLogFilesAction::execute()` itself (defence in depth). Anything else returns a `log.invalid_filename` failure or a 404 from the route binding — the disk path is never built from raw input.

- **Active-file deletion refused.** `LogFileQuery::isActive()` flags today's daily file (`laravel-{today}.log`) and any file with an `mtime` within the last 5 seconds. `DeleteLogFilesAction` rejects flagged files per-item with `reason: 'active_file_protected'`, so a bulk submit cannot accidentally truncate the file Laravel is currently appending to.

- **`role:system_admin` route gate, no permission entry.** The viewer is intentionally **not** added to `config/permission-resources.php`. Granting an `admin` role does not unlock it; only the dedicated `system_admin` role does. Non-system-admin users get a 403 on the route and never see the menu item — the feature is invisible to them.

- **Per-line read cap of 64KB.** `LogEntryQuery` calls `fgets($handle, 65536)`, so a pathological single-line entry of unbounded size cannot exhaust process memory. Long lines truncate cleanly without aborting the request.

## 2026-04-25 -v.13.4.3

### Patch release — rich vertical tabs + datatable per_page upper bound

Brings a richer vertical tab presentation through the `TB` builder (icon tile, description line, trailing badge or check) and an opt-in upper bound on the `?per_page=` query parameter handled by `DatatableQueryBuilder`. Both are additive — no breaking changes. `sk:update` ships the new TabBuilder Vue components, the rewritten `_tabs.scss`, and the EN/TR `sk-setting.tab_descriptions` language keys; `composer update` is sufficient for the package-tier `max_per_page` config.

#### Added

- **`TB.item()` rich vertical tab fluent methods.** Four new fluent methods: `.description(text)` for a secondary line under the label, `.iconColor(color)` for a colored icon tile preset (13 colors: `blue`, `amber`, `emerald`, `purple`, `teal`, `red`, `indigo`, `slate`, `pink`, `orange`, `cyan`, `green`, `yellow`), `.badge(value, severity?)` for a trailing badge (5 severities: `success`, `warn`, `info`, `danger`, `secondary`), and `.checked()` for a trailing green check (takes precedence over `badge`). Existing tab definitions render unchanged. The shipped Settings → General page uses the new API (per-tab description + icon color) as the canonical example. New i18n block `sk-setting.tab_descriptions` covers the seven settings tabs.

- **`STARTER_KIT_DATATABLE_MAX_PER_PAGE` env var + `config('starter-kit.datatable.max_per_page')`.** Opt-in upper bound on the `?per_page=` query parameter for `DatatableQueryBuilder`. Defaults to `100` when the config key is absent.

#### Security

- **`DatatableQueryBuilder` — `?per_page=` upper bound enforced.** Previously a client could send `?per_page=99999` and force the builder to materialise an entire table into a single payload. The new ceiling (`config('starter-kit.datatable.max_per_page')`, default 100) silently clamps the value — anything inside the cap behaves identically, so legitimate callers are unaffected.

#### Improved

- **Vertical tab sidebar — PrimeVue Card wrap via `.isCard(true)`.** Set at the tabs level (not per-tab), the vertical sidebar wraps in a Card with reduced internal padding. Combined with the new icon tile + description fields, the Settings page sidebar now matches modern admin-panel layouts out of the box.

#### Fixed

- **Branding — legacy "Starter Kit 12" references.** Two places still read "Starter Kit 12" — `config/scramble.php` API description and `app.blade.php` fallback title; both now read "Starter Kit 13".

## 2026-04-24 -v.13.4.2

### Patch release — Tiptap editor input, password generator, dashboard welcome message + security hardening

Introduces a rich-text `FB.editor()` FormBuilder field (Tiptap v3 under the hood) paired with a server-side `HtmlSanitizer` utility, a crypto-safe password generator on `FB.password()`, and an admin dashboard welcome message authored through the editor on **Settings → General**. File upload gains an optional `folder_name` parameter so editor-scoped uploads stay grouped, and the FileManager now surfaces a dedicated error for 413 Payload Too Large. All additive — no breaking changes. `sk:update` ships the published files (new Vue components, `HtmlSanitizer`, language keys); `composer update` is sufficient for package-tier changes.

#### Added

- **Tiptap-based `FB.editor()` FormBuilder input.** A new form field type backed by Tiptap v3 with bubble menu, link / image / table / task list / text align / text color / text style and placeholder extensions. Toolbar layout is chosen via `.toolbar('minimal' | 'standard' | 'full')`; image uploads route through the FileManager context with an optional folder-grouping parameter, and companion components (`EditorColorPalette`, `EditorImagePicker`) cover the color and image picker flows. Translations live in `lang/{en,tr}/sk-editor.php`. Content flows through the new `App\Support\HtmlSanitizer` on save, so only allowlisted tags / attributes / URL schemes are persisted.

- **`FB.password().generator()` — crypto-safe password generator.** Opt-in fluent method that adds a generate button next to the password field, backed by `crypto.getRandomValues()`. Defaults are intentionally stricter than `Password::defaults()` (16 characters, mixed case + letters + digits + symbols) so every generated value passes the framework-wide password policy on the first submit. Paired with a rewritten custom eye toggle so `password` and `password_confirmation` fields render identically inside `InputGroup` containers. PrimeVue `<Password>` is now only used when `.feedback()` opts in to the strength meter — every other usage falls through the lighter `InputText + eye` path. Enabled on the admin User form out of the box.

- **Admin dashboard welcome message.** **Settings → General** gains an optional `welcome_message` WYSIWYG field rendered through `FB.editor()`. The dashboard shares the sanitized HTML as an Inertia prop, and `resources/js/pages/Admin/Dashboard/Index.vue` renders it inside an `sk-prose` container via `v-html`. The value is sanitized on write (FormRequest `prepareForValidation` hook) and on read (DashboardController defense-in-depth pass) so pre-existing rows with hostile markup cannot surface even if the on-disk value drifts.

- **File upload `folder_name` parameter.** `POST /file-manager/files` now accepts an optional `folder_name` string (nullable, `max:100`, strict regex: letters / digits / space / dash / underscore only — path-traversal and arbitrary-character risk closed at validation). When supplied, `UploadFileAction::ensureManagedFolder` atomically ensures a root-level folder with that name exists in the current context and stores the upload inside it. The welcome-message editor uses this to keep all inline image uploads grouped under a single "Welcome Message" folder without a read query ever writing side-effects. Frontend `EditorImageUploadConfig` exposes the same field via `folderName`.

#### Security

- **`App\Support\HtmlSanitizer` — allowlist for tags, attributes, and URL schemes.** New utility that strips every tag, attribute and URL scheme not on a small allowlist from editor payloads. URL handling flipped from blocklist to allowlist: relative URLs plus `http://`, `https://`, `mailto:` and `tel:` are permitted — anything else (`blob:`, `data:`, `file:`, `ftp:`, `javascript:`, `vbscript:`) is rejected. Covered by a dedicated `tests/Unit/HtmlSanitizerTest.php` suite.

- **`SettingService::normalizeValue()` — HTML sanitize on every write path.** `setValue()` and `setGroup()` now pass every value through a shared `normalizeValue()` hook. Keys listed in a new `HTML_SAFE_KEYS` whitelist (currently `general.welcome_message`) are run through `HtmlSanitizer::sanitize()` before hitting the database, so non-FormRequest writes — tinker sessions, scheduled commands, queued jobs — cannot leave unsanitized HTML behind.

- **Dashboard welcome message — defense-in-depth read sanitize.** `DashboardController::index` runs the stored welcome message through `HtmlSanitizer::sanitize()` a second time before sharing it to Inertia. Historical rows written before the write-path sanitize landed are neutralised, and a drifted or manually-poisoned DB value cannot reach the browser.

- **`UploadFileAction::ensureManagedFolder` — concurrency-safe managed folder creation.** The ensure path runs inside `DB::transaction` with `lockForUpdate` on the candidate row, falls back on a `QueryException` catch for the unique-constraint race, and restores soft-deleted folders via `withTrashed()` instead of creating a duplicate. Combined, the three layers close the race window where two parallel editor uploads could either deadlock on the same folder name or resurrect a soft-deleted row by creating a sibling that trips the unique index.

- **`UploadFileRequest` — `folder_name` input strictly validated.** The new field uses `nullable|string|max:100|regex:/^[\pL\pN _-]+$/u`; path-traversal and arbitrary-character content is rejected at the FormRequest boundary, not downstream.

#### Improved

- **FileManager upload error messages.** The client composable now recognises HTTP 413 (Payload Too Large) and surfaces the dedicated `too_large` translation (EN + TR) instead of a generic failure string; every other non-200 response carries the raw status code alongside the message, so upload failures are easier to diagnose without opening the devtools network tab.

- **Password field default render path.** Beyond the `.generator()` addition above, the default `FB.password()` render now uses `InputText` + a custom eye toggle rather than PrimeVue `<Password>`. Fixes the long-standing issue where `<Password>`'s built-in eye icon disappeared inside `InputGroup` addons, and makes `password` / `password_confirmation` fields render identically. `<Password>` is still used when `.feedback()` is called (strength meter path). New i18n keys: `generate_password`, `password_generated`, `password_generated_detail`, `show_password`, `hide_password` (EN + TR).

#### Fixed

- **`SettingsDefaultsQuery` read path no longer writes.** The previous release read the **Settings → General** screen and, as a side effect, tried to `firstOrCreate` a "Welcome Message" folder through `resolveWelcomeMessageFolderId()`. On installs with a soft-deleted folder of that name, the unique index rejected the insert and the admin saw a 500 on a pure read. The folder ensure path is now owned exclusively by `UploadFileAction::ensureManagedFolder` at upload time, and `SettingsDefaultsQuery` is side-effect-free again. The frontend `welcome_message_folder_id` Inertia prop binding is gone as well — the editor uses `folderName` directly.

- **Editor upload — stale `blob:` URLs no longer leak into the form payload.** `EditorInput.vue` now manually syncs the parent `v-model` after `setContent({ emitUpdate: false })`, so replaced / broken `<img src="blob:...">` fragments from a just-completed upload no longer travel to the server in the submitted HTML.

## 2026-04-22 -v.13.4.1

### Patch release — API response hardening + Postman/Apidog sync + OAuth UUID fix

This release bundles the end-to-end API response envelope rework (trace-id pipeline, centralised exception handler, leak-closing controller patches) with two new API client integrations (Postman and Apidog sync) and a pair of install-time fixes (OAuth UUID compatibility, automatic Passport personal access client). Most changes are additive (new body fields + headers, new admin buttons), but three API-response behavioural breaks matter for strict clients — see [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade). Fresh installs pick everything up automatically; existing projects should follow the upgrade guide. `sk:update` ships the published files; controller patches and the post-install Passport step are manual.

#### Security

- **Controller `$e->getMessage()` leaks closed (11 sites).** `FileManagerController` (bulkDelete/createFolder/renameFolder/moveItem/deleteFolder/upload/deleteFile), `Api/UserController::destroy`, and `Api/Auth/AuthController::login` + `twoFactorChallenge` swapped the `to_api(null, $e->getMessage(), 4xx)` pattern for `throw ApiException::*`. The client-facing message is unchanged, but the response now routes through the central handler — the `trace_id` is aligned, 500+ errors are logged, `X-Correlation-ID` is echoed. Moving away from raw `LogicException::getMessage()` closes the door on accidental internal-message leaks during future refactors.

- **`abort($code, 'msg')` no longer leaks the raw message.** The `HttpExceptionInterface` branch now uses the fixed `defaultMessageForStatus()` table instead of `$e->getMessage()`. `abort(400, 'SQL error: ...')` now returns `"Bad request."` in the body; the internal detail only surfaces in `debug.message` while `APP_DEBUG=true`. Use `throw ApiException::badRequest('...')` for controlled messaging.

- **`Api/AuthController` returns `UserResource` instead of a raw User.** `register`, `login` (default kind), `twoFactorChallenge`, and `me` now produce `data.user` via `UserResource::toArray()`. Raw Eloquent serialisation relied on `$hidden`; a future sensitive column could leak if forgotten. The resource makes the wire contract explicit.

#### Added

- **Postman sync — admin button + CLI.** New "Sync to Postman" action on the API Routes page (and `php artisan postman:sync`) pushes the Scramble OpenAPI spec through Postman's `/import/openapi` endpoint with `folderStrategy=Tags` so tags become folders. Each sync imports a fresh collection, persists the newly issued UID to the settings store, then best-effort deletes the previous collection — an `import-first, delete-after` sequence so a transient Postman outage or invalid token never leaves the workspace without a working collection. Configuration: Settings → API Clients → Postman card (API Key + Workspace ID; collection ID is managed automatically).

- **Apidog sync — admin button + CLI.** Same pipeline pushes to Apidog's `POST /v1/projects/{projectId}/import-openapi` endpoint with inline JSON input and `OVERWRITE_EXISTING` merge behavior. Also available as `php artisan apidog:sync`. Configuration: Settings → API Clients → Apidog card (Access Token + Project ID).

- **Settings → API Clients tab.** Single tab hosts both Postman and Apidog configuration as separate cards. Secret fields (`postman.api_key`, `apidog.access_token`) are encrypted at rest through the existing `sensitive_keys` list in `config/settings.php`. The previous `POSTMAN_*` `.env` keys are no longer used — existing values are migrated into the settings table.

- **Shared `OpenApiExporter` helper.** Both sync Actions share a single exporter that runs `scramble:export`, writes to a per-request unique path under `storage/app/postman/`, and cleans up in a `finally` block — the CLI command and the admin UI button can run concurrently without racing on a shared file. The spec is emitted **unchanged**: no content-type rewriting, so the pushed collection mirrors the real server contract (clients are free to toggle the body view between raw / form-data in their own UI).

#### Improved

- **Success and error responses share a single `trace_id`.** The new `AssignTraceId` middleware prepended to the `api` group generates a UUID per request and both branches (`ApiResponse::toResponse` on success, `ApiExceptionHandler` on error) pick it up from `$request->attributes`. Body `trace_id` + header `X-Request-ID` + a sanitised echo of the client's `X-Request-ID` as `X-Correlation-ID`. In support scenarios, client-side logs and server-side logs correlate via one id.

- **`ModelNotFoundException` message includes the model name.** `"The requested resource was not found."` → `"User not found."` (or `Role`, `Product`, …). `ApiExceptionHandler::modelNotFoundMessage` resolves it via `class_basename($e->getModel())`. Matches the previous AGENTS.md contract; no security impact since the model class name is already inferable from the URL.

- **`Retry-After` header propagated on 429 responses.** All rate-limit headers from `ThrottleRequestsException::getHeaders()` (`Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) are copied to the response. Throttled clients can read the standard header instead of parsing it out of the message string.

- **`simplePaginate()` support.** `to_api(Model::simplePaginate(15))` no longer raises a type error; lightweight pagination with just `meta.has_more` is now supported. `LengthAwarePaginator` and `CursorPaginator` behaviour unchanged.

- **`to_api(paginator, 'msg', 201)` no longer loses pagination meta.** Helper's paginator detection now runs before the 201/202 branches; batch-create style endpoints emit meta too (previous release serialised the paginator as a raw object — silent bug).

- **`ApiResponse` DRY + `final`.** The meta builder for `paginated()` and `paginatedCollection()` was extracted into a single private helper. The class is now `final` to prevent subclass invariants drifting. No behavioural change to the controller return-type signatures; public API surface unchanged.

- **Scramble `ApiResponseExtension` schema descriptions enriched.** Each envelope field now has a definition + example + validation-rule description. Multi-status schema (distinct `Response` objects for 201 / 204 / 4xx / 5xx) is deferred — `TypeToSchemaExtension` does not model it directly, so `OperationExtension` will take over in a follow-up.

#### Fixed

- **OAuth migrations UUID-compatible.** `oauth_access_tokens.user_id` and `oauth_auth_codes.user_id` are now `foreignUuid` (previously `foreignId` / `bigint unsigned`); `oauth_clients.owner_*` is now `nullableUuidMorphs`. Combined with the UUID `users.id` primary key shipped by this starter kit, the previous mismatch surfaced as `SQLSTATE 1265: Data truncated for column 'user_id'` on login — the API login flow is now clean out of the box.

- **`site:install` provisions the Passport personal access client automatically.** A `passport:client --personal --provider=users` step was added between `passport:keys` and the admin-user seed. Fresh installs can issue API tokens right away; previously the operator had to remember to run that command manually after `site:install`.

- **202 Accepted dead code removed.** The `'Operation queued.'` fallback for `to_api($data, '', 202)` never fired (the default `$message` was truthy). Helper simplified to a single logical flow.

- **`ApiResponse::toResponse()` honours the `$request` parameter.** The previous implementation accepted the `Responsable::toResponse($request)` signature but ignored the argument — integration with the new middleware depends on this parameter, which is now actually consumed.

- **Exception handler `match` ordering criticality documented.** `ApiException extends HttpException`, so it must be matched before the `HttpExceptionInterface` branch — otherwise custom API exceptions would fall through to the generic abort() handling. The fragile ordering is pinned by a comment and by the regression suite (`tests/Feature/Api/ApiResponseTest.php`).

#### New

- **Regression test file: `tests/Feature/Api/ApiResponseTest.php` (16 tests, 57 assertions).** Covers the envelope shape, exception → status mapping, trace id agreement, empty 204 body, `Retry-After` propagation, `debug` guarded by `APP_DEBUG=true`, and the sanitised `X-Correlation-ID` echo. Example copy available at `vendor/lvntr/laravel-starter-kit/tests/examples/ApiResponseTest.php`.

- **Expanded `sk:update` coverage.** `app/Http/Middleware/AssignTraceId.php` and `app/Helpers/sk-helpers.php` joined the safe-update list; `php artisan sk:update` now syncs both automatically. `ApiResponse.php` and `ApiExceptionHandler.php` were already tracked.

#### Breaking

Detailed migration steps in [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade). Summary:

- `abort($code, 'custom message')` no longer surfaces the message — use `throw ApiException::*` instead.
- `ModelNotFoundException` message now includes the model name (`"User not found."`). Frontend regex matches may need to loosen.
- `Api/Auth/AuthController` `data.user` is limited to `UserResource::toArray()` output. If you depended on a raw-model field, extend the resource.

## 2026-04-21 -v.13.4.0

### Minor release — Security hardening sprint

A parallel code-review sweep surfaced ~37 findings — 13 HIGH, 14 MEDIUM, 4 LOW. 36 are closed in this release; 1 HIGH (Passport private-key rotation in git history) is a manual operator step. Most patches touch **published** files (the files `sk:install` copies into your app), so existing consumer apps need to apply the diffs in [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade). Fresh installs pick everything up automatically. The rare package-tier changes (HSTS `preload`, stub updates) arrive via `composer update lvntr/laravel-starter-kit`.

#### Security

- **Self-delete blocked on `UserPolicy::delete` + null guard on API `UserController::destroy`.** `UserPolicy::delete` previously returned `true` when actor === target, so any authenticated user holding `users.delete` could remove themselves via `DELETE /api/v1/users/{self}`. The self-branch now returns `false` — the only supported self-removal path is the password-confirmed Fortify flow in Profile. `Api\UserController::destroy` also returns a clean 401 when `$request->user()` is null (stale/expired bearer), replacing the previous `(string) null = ''` cast that would log an empty performer id.

- **`CreateRoleAction` + `UpdateRoleAction` wrap role + permission sync in `DB::transaction`.** `Role::create(...)` followed by `->syncPermissions(...)` ran outside a transaction; a permission-cache race or a connection drop between the two writes could leave a role row with no permissions. Both actions now run inside `DB::transaction(...)`; `RoleCreated` / `RoleUpdated` dispatch after commit so listeners observe a consistent state.

- **`UpdateAuthSettingsAction` wraps the 2FA revoke loop in `DB::transaction`.** When the admin toggles `auth.two_factor` off, the action writes the setting and then clears `two_factor_secret` / `two_factor_recovery_codes` / `two_factor_confirmed_at` on every user. A failure mid-loop used to leave the system in a half-revoked state — the setting said "2FA off" but some users still had active TOTP secrets. The full operation is now atomic.

- **`LogoutUserAction` null-safe token revoke.** The API logout endpoint called `$user->token()->revoke()`; if the request hit the controller without an active access token (stale token, cleared cache, worker race) the chained call threw `Error: Call to a member function revoke() on null` and the endpoint 500'd. Now uses `?->revoke()` — returns a clean 204 even when the token is already gone.

- **FileManager subtree walks reduced from N queries to 1.** `BulkDeleteAction::collectDescendantIds` and `DeleteFolderAction::collectDescendantIds` issued a `FileFolder::find` per hop when walking the subtree of a folder being deleted — a 50-level tree meant 50 serial queries, and the cost grew with siblings, giving attackers a request-timing DoS knob. Both actions now load the owner-scoped `(id, parent_id)` map in one `select` and walk the tree in PHP with a visited-set cycle guard for corrupt data.

- **SMTP `encryption=none` now disables TLS correctly.** The shipped Mail settings screen offered a "No encryption" option, but `SettingsServiceProvider` rebroadcast the literal string `'none'` into `config('mail.mailers.smtp.encryption')`. Laravel's SMTP transport treats any non-null value — including `'none'` — as "use this TLS mode", so saved "No encryption" configurations fell back to the default STARTTLS upgrade on the first connect and could fail against servers that do not offer it. The provider now maps `'none' → null` on the outbound config write.

- **`ApiExceptionHandler` — exception-message leak + `X-Request-ID` log injection.** The `default` arm of the exception→status mapping returned `config('app.debug') ? $e->getMessage() : 'A server error occurred.'`; in any environment where `APP_DEBUG` was accidentally left on, unhandled exceptions leaked stack-trace-grade detail to API consumers. The handler now returns the generic message unconditionally; debug details live only in `Log::error` plus the `debug` block that is already gated on `APP_DEBUG`. The trace id is now always server-generated via `Str::uuid()`; any `X-Request-ID` header sent by the client is accepted only as correlation metadata after a charset + length-cap sanitiser (`[A-Za-z0-9._-]`, ≤128 chars), then logged as `client_request_id` — a malicious client can no longer inject a CRLF payload or a fake trace id into the application log.

- **`SecurityHeaders` HSTS directive gains `preload`.** The baseline HSTS header moved from `max-age=31536000; includeSubDomains` to `max-age=31536000; includeSubDomains; preload`, making the deployment eligible for the HSTS preload list. Ships from the package `src/` — picked up automatically by `composer update`.

- **Password policy raised to 10+ / mixed case / digits / symbols.** `AppServiceProvider` now installs a project-wide `Password::defaults(...)` that every FormRequest relying on the default picks up automatically (registration, password reset, password confirm, profile password change). Existing users' passwords are not invalidated — only new passwords are measured against the stronger rule.

- **Axios CSRF + credential defaults.** `resources/js/app.ts` now sets `axios.defaults.withCredentials = true`, `xsrfCookieName = 'XSRF-TOKEN'`, `xsrfHeaderName = 'X-XSRF-TOKEN'`, plus `X-Requested-With: XMLHttpRequest` and `Accept: application/json`. The admin UI calls Fortify endpoints (2FA, sessions, password-confirm) directly via Axios; without `withCredentials` and the XSRF header the browser sent the session cookie but not the CSRF token on mutating requests, so a compromised origin could interact with the session without the CSRF check the web flow relies on.

- **2FA QR code rendered through `<img src="data:image/svg+xml;base64,...">` instead of `v-html`.** Fortify returns the QR code as an SVG string. The previous `v-html="qrCodeSvg"` worked but would have evaluated `<script>` or `onload` attributes in the SVG if a man-in-the-middle (or a compromised Fortify override) slipped them in. The new approach base64-encodes the SVG into an `<img>` data URL — the `<img>` sandbox does not execute inline scripts, even if the SVG contains them.

- **`useDefinition.load()` / `loadAll()` no longer flip `loaded.value = true` on a failed fetch.** The composable is the one-stop loader for the definition JSON that drives datatable / form option dropdowns. It previously chained `.then(r => r.json())` directly — if the fetch failed (network error, 500, parse failure) `loaded.value` stayed `true` and the UI kept rendering stale / empty option lists without any console feedback. Both methods are now wrapped in `try/catch`, `res.ok` is checked, errors surface to the console, and `loaded.value` stays `false` on failure so consumers can retry.

- **Eleven `FormRequest::authorize(): return true;` offenders closed.** The following requests — admin user store, API user store, admin role store, admin settings (auth/general/mail/storage/filemanager/turnstile), test-mail, destroy-sessions — now delegate `authorize()` to the matching `*.create` / `*.update` permission (destroy-sessions checks `$this->user() !== null`). The `CheckResourcePermission` middleware already enforced these at the route level, but moving the check into the request closes the defense-in-depth gap that would open the moment a controller action was invoked off-route (tests, internal dispatch) or the action map drifted out of sync with new route names. Public auth endpoints (`Api/Auth/*Request`) and FileManager context-based requests are intentionally left alone.

- **2FA challenge is now strictly single-use.** `TwoFactorChallengeAction` previously left the `api:2fa_challenge:{uuid}` cache entry intact on a wrong TOTP / wrong recovery code / empty submit, so an attacker with a valid challenge id got the full 5-minute TTL × `throttle:5/min` window to try codes. Every failure arm now calls `Cache::forget($cacheKey)` — the challenge id works exactly once; subsequent attempts hit `invalidChallenge()` and the client must re-login to get a fresh uuid.

- **`SettingService::getValue` / `getGroup` read from the `allGrouped()` cache + `setGroup()` wrapped in `DB::transaction`.** The hot read path previously ran one query per call even though a cache layer existed for the full `allGrouped()` result. Settings-heavy request paths (Dashboard, FileManager, Admin pages) saved a handful of round-trips per request. The bulk write path is also now atomic — a partial failure during a multi-setting save no longer leaves the DB in a mixed state.

- **`MoveItemRequest` — typed `item_id` based on `item_type`.** The rules used to accept any `item_id` value for any `item_type`. The effective rule is now `integer|min:1` for `item_type=file` and `uuid` for `item_type=folder`, matching the DB schema; `item_type` itself uses `Rule::in([...])` instead of the `string|in:...` string form.

- **`DeleteFolderRequest` — explicit FormRequest replaces a bare `Request`.** `FileManagerController::deleteFolder` previously accepted a raw `Request`, built the context in the controller, and called the authorizer directly. The new `DeleteFolderRequest` extends `FileManagerRequest`, runs the shared context rules, and exposes `$request->context()` — identical surface to the other FileManager endpoints; controller drops two lines of boilerplate.

- **`UserController::uploadAvatar` runs an explicit `Gate::authorize('update', $user)`.** `UploadAvatarRequest::authorize()` already delegates to `UserPolicy::update` when a `{user}` route param is bound, but the redundant Gate call in the controller mirrors the belt-and-braces pattern used on view/update/delete and keeps the check visible when reading the controller in isolation.

#### Security — manual operator step

- **GV-H1 — Passport private keys rotation.** `storage/oauth-private.key` and `storage/oauth-public.key` live in git history for legacy installs that committed them before the `.gitignore` rule landed. [docs/UPGRADE.md §6](https://starter-kit.lvntr.dev/docs/upgrade#6-gv-h1--passport-private-key-rotation-critical-manual) documents the `git filter-repo` + `passport:keys --force` + `passport:purge` + team-wide `git reset --hard` sequence; this cannot be automated by the package. If your repo never committed the key files, skip this step.

#### Changed

- **`LOG_LEVEL` default is now `error`.** `.env.example` previously shipped `LOG_LEVEL=debug`, which in production (if committed verbatim) fills the log with SQL traces, Passport token debug info and similar — noisy and occasionally sensitive. Production profiles should ship `error` or `warning`.

- **`laravel/tinker` moved to `require-dev`.** Tinker is a developer convenience — shipping it as a production dependency pulled PsySH and its transitive chain into every container build. Local dev still installs it because it's in `require-dev`.

- **`.env.example` gains Passport key + Turnstile placeholders.** Two commented-out `PASSPORT_PRIVATE_KEY` / `PASSPORT_PUBLIC_KEY` stubs document the env-based key-loading path (the recommended alternative to committing `storage/oauth-*.key`), and an uncommented `TURNSTILE_ENABLED=false` + empty site/secret keys make the Turnstile middleware a no-op on fresh installs until the admin turns it on.

- **Inertia `appEnv` / `appDebug` shared props no longer leak in production.** `HandleInertiaRequests::share` used to return `config('app.env')` + `config('app.debug')` unconditionally. In production this leaked the environment name and advertised whether `APP_DEBUG` was on to every authenticated user. Both keys now return `null` / `false` under `app()->environment('production')`; non-prod keeps the real value for the dev overlay.

- **CORS preflight cache raised from 0 to 7200 seconds.** `config/cors.php` previously shipped `max_age => 0`, forcing the browser to re-run a preflight on every mutating request. With `max_age=7200` SPA / mobile clients cache the OPTIONS response for 2 hours.

#### Fixed

- **`useDialog` / `useImageLightbox` — 300 ms timer leak.** Both composables started a `setTimeout` in `close()` to delay DOM removal so the exit animation could play. A rapid `open → close → open` sequence could queue two timers, with the trailing one firing after the dialog was re-opened and cancelling the render. A module-level timer ref is now cleared on both `open()` and `close()` entry; the timeout body nulls the ref when it fires.

- **`SkForm` dirty-form guard — stops parent prop updates from wiping user input.** The `watch(derivedDefaults, ...)` block unconditionally reset the form to the new defaults whenever the parent passed a new object. If the user was halfway through filling a form and the parent polled (e.g. a sibling datatable refresh triggered a shared-state update), their in-progress input was wiped. The watcher now checks `internalForm.isDirty` — when the form is dirty, the new values are recorded as defaults (so a subsequent `reset()` picks them up) but the live form state is preserved.

- **`SkDatatable` URL filters — `api.get` + `Promise.allSettled`.** The URL-driven filter loader used bare `fetch(...)` + `Promise.all`, so a single 500 on one filter's options endpoint poisoned the whole filter bar via an unhandled rejection. The loader now uses the shared `api.get<T>()` helper (picks up the Axios defaults + XSRF) and `Promise.allSettled`, so each filter is independent; a failing endpoint falls back to an empty list with a console warning. Same file flips `let activeMenuItems` → `const activeMenuItems` (the ref was never re-assigned).

- **`TwoFactorTab.enableTwoFactor` awaits the Inertia reload.** The original code fired `router.reload({ only: [...] })` without awaiting and immediately moved on to `loadQrAndSetupKey()`. On a slow connection the QR fetch could race the reload and render a stale screen. `router.reload` is now wrapped in a promise that resolves on `onFinish`.

- **`ProfileInfoTab` / `UserForm` — drop `as any` avatar casts.** Two `(x as any)?.avatar_url` accesses were replaced with typed shapes — no behaviour change, but the cast hid a legitimate TypeScript error if the backing type ever dropped the `avatar_url` accessor.

- **`DashboardController::index` gains an explicit `: Response` return type.** Closes the last Larastan `return_type_missing` finding under the project's configured level.

### Upgrade

`composer update lvntr/laravel-starter-kit --with-all-dependencies` picks up only the package `src/` tier (HSTS `preload`, stub updates). Every other fix above lives in published / stub-backed files. Follow [docs/UPGRADE.md](https://starter-kit.lvntr.dev/docs/upgrade) for the full diff-style patch list and smoke-test checklist.

## 2026-04-20 -v.13.3.3

### Patch release — Windows build fix for Builder core imports

#### Fixed

- **Windows production build failed with `Could not load .../FormBuilder/core`.** `FormBuilder`, `DatatableBuilder` and `TabBuilder` each expose a `core/` directory whose `index.ts` is imported as `@lvntr/components/<Builder>/core`. On some Windows setups Vite's resolver skipped the directory→`index.ts` step and fell through to `vite:load-fallback`, which tried to read the directory as a file and raised `ENOENT`. Fix: a sibling `core.ts` barrel file now re-exports from `./core/index` for each of the three builders, so the import resolves to a real file on every platform. macOS/Linux behaviour is unchanged, and existing subpath imports like `/core/builder` are untouched. Fixes lvntrdev/laravel-starter-kit#1.

## 2026-04-19 -v.13.3.2

### Patch release — security hardening, user audit events, Logo API envelope, media-delete policy, permission-middleware cache correctness, test bootstrap

A batch of latent bugs uncovered by a full test-suite audit, plus a dedicated security review pass that closed a privilege-escalation path in the admin user flow, stopped the settings screen from leaking SMTP/S3/Turnstile secrets to the frontend, and brought the API auth flow to parity with the web flow (email verification + two-factor challenge). Most of the original bugs only showed up under specific runtimes (Octane/queue workers, fresh clones without `site:install`) or silently swallowed side-effects (audit log for user writes).

#### Security

- **Privilege escalation via unvalidated role assignment — admin user flow.** `StoreUserRequest` and `UpdateUserRequest` used to validate the `role` field with `Rule::exists('roles', 'name')` only, so any user holding `users.create` or `users.update` could submit `role=system_admin` in a raw HTTP request regardless of what the admin UI dropdown offered — instantly granting themselves the super-admin role that bypasses every authorization gate via `Gate::before`. `UpdateUserRequest` additionally had no rank check on the target user, so a lower-ranked actor could edit (or demote) a higher-ranked one. Fix: `role` is now validated with `Rule::in(...)` built from `RoleSelectOptionsQuery`, the same hierarchy-aware list that feeds the dropdown (`sort_order >= actor's min sort_order`, `system_admin` excluded for non-system_admin actors). `UpdateUserRequest::authorize()` additionally rejects edits where the target's top-ranked role outranks the actor's. A user holding `users.*` as a direct Spatie permission without any assigned role is treated as the lowest possible rank — they can no longer assign any role or edit anyone other than themselves; the previous `(int) null = 0` fallback accidentally opened the full role list including `system_admin`.

- **Settings secrets no longer leak to the frontend.** The admin **Settings** page was sending `mail.password`, `storage.spaces_secret`, `storage.aws_secret` and `turnstile.secret_key` in plain text as Inertia props for any user with `settings.read`. Even values that lived only in `.env` leaked out through the `config()` fallback. Fix: `SettingsDefaultsQuery` now returns `null` for every secret field and adds a parallel `*_is_set: bool` flag. The admin UI renders a `••••••••` placeholder when a value is set and submits an empty string to keep the current secret; writing a non-empty value replaces it. The new `tests/Feature/Admin/Settings/SecretsDisclosureTest` asserts the Inertia payload never contains the raw secret string anywhere.

- **`storage.aws_secret` now stored encrypted at rest.** `config/settings.php` gained `storage.aws_secret` in its `sensitive_keys` list — it previously had `mail.password`, `storage.spaces_secret` and `turnstile.secret_key` but not the AWS counterpart, so S3 secrets saved through the UI lived as plaintext in the `settings` table. `SettingService` encrypts every listed key with `Crypt::encryptString` on write and decrypts on read.

- **`check.permission` middleware now fails closed in production.** The middleware used to allow the request through when a route-derived permission (e.g. `users.read` for `users.index`) was not seeded in the database. In production this silently unprotected any new route whose permission row was forgotten. The middleware now throws `AuthorizationException` (403) when running under `app()->environment('production')` and `Log::warning`s the unseeded permission in non-production environments — dev ergonomics preserved, the production foot-gun is closed.

- **Test-mail endpoint no longer reflects raw exception details.** `SettingsController::testMail()` used to flash the SMTP exception message (host / username / TLS details) back to the browser. The message is now written to `Log::error` with class + message context; the user sees a generic "Failed to send test email. Check the server logs for details." — same success/failure signal without the information disclosure.

- **API auth — email verification and two-factor parity with the web flow.** The API previously handed out an access token immediately on register and on any successful password login, bypassing the same email-verification and 2FA checkpoints the web flow enforces. All three `POST /api/v1/auth/*` endpoints were reworked:
    - **`register`** — when Fortify's `emailVerification` feature is enabled (the default), no token is issued on registration. The endpoint creates the user, fires `Illuminate\Auth\Events\Registered` (so Fortify's notification pipeline sends the verification link) and returns `{ data: { user, requires_verification: true } }` with 201. When the feature is disabled, the previous token-on-register behaviour is kept.
    - **`login`** — returns a discriminated payload:
        - `{ user, token }` — normal success
        - `{ requires_verification: true }` — credentials are valid but the email is not verified (when the verification feature is on)
        - `{ requires_two_factor: true, challenge: "<uuid>" }` — credentials are valid but the account has confirmed 2FA; a single-use challenge id is issued with a 5-minute cache TTL. No access token is issued yet.
    - **`two-factor-challenge`** — new endpoint `POST /api/v1/auth/two-factor-challenge` (throttled `5/min`). Accepts `{ challenge, code }` for TOTP or `{ challenge, recovery_code }`. On success it returns `{ user, token }`. TOTP is verified via Fortify's `TwoFactorAuthenticationProvider`; recovery codes are matched with `hash_equals` and consumed via `replaceRecoveryCode` so they cannot be reused. Invalid / unknown / expired challenges return 401.

    **Breaking for API consumers** — existing clients that expected `{ user, token }` on every 2xx response from `register` / `login` must now branch on `data.requires_verification` and `data.requires_two_factor` flags, and complete the challenge at `/api/v1/auth/two-factor-challenge` before receiving a token when 2FA is confirmed on the account. Non-2FA, verified users keep seeing the old shape.

- **Settings `required` validation now matches the UI secret indicator.** `UpdateMailSettingsRequest` and `UpdateTurnstileSettingsRequest` previously only checked the DB row when deciding whether a secret was "already set"; if the value lived only in `.env`, the UI's `*_is_set` flag reported `true` (because `SettingsDefaultsQuery` falls back to `config()`) but submitting the form with a blank password / secret_key triggered a confusing `required` validation error. The `required` branch now mirrors the query — DB row OR config fallback — so env-backed installations no longer see the spurious error.

- **IDOR on admin avatar upload / delete.** `POST /users/{user}/avatar` and `DELETE /users/{user}/avatar` resolved to no permission under `CheckResourcePermission` because the route actions `uploadAvatar` / `deleteAvatar` were not in the middleware's `ACTION_ABILITY_MAP`; the middleware returned `$next($request)` without a permission check. `UploadAvatarRequest::authorize()` also returned `true` unconditionally. Any authenticated + email-verified user (including `user` role with only `dashboard.read`) could overwrite or delete any other user's avatar — system admin included. Fix: the action map now contains `uploadAvatar => update` and `deleteAvatar => update`; `UploadAvatarRequest::authorize()` delegates to `UserPolicy::update` when a `{user}` route param is present (self-upload via Profile route is preserved); `SettingsController::deleteAvatar` calls `Gate::authorize('update', $user)` explicitly.

- **Admin `UserController` and API `UserController`: rank-hierarchy guard on view / update / delete.** `GET /users/{user}/data`, `GET /users/{user}/edit`, `DELETE /users/{user}`, `PATCH /api/v1/users/{user}` and `DELETE /api/v1/users/{user}` used to rely solely on the `users.read` / `users.update` / `users.delete` permission and the (admin-only) `UpdateUserRequest::authorize()` rank check. A lower-ranked admin holding the permission could still read or delete a higher-ranked user through the data endpoint or the API. Fix: `UserPolicy::view / update / delete` now run the same `canManage()` rank check used by the admin update request (system_admin bypasses, role-less actors are treated as the lowest rank). Admin and API controllers call `Gate::authorize('view' / 'update' / 'delete', $user)` on every cross-user operation. The admin `UpdateUserRequest` and the API `UpdateUserRequest` both delegate `authorize()` to `UserPolicy::update` so the rank check is uniform across flows.

- **`POST /api-routes/regenerate-docs` was reachable by any authenticated user.** The route action `regenerateDocs` was not in the `ACTION_ABILITY_MAP`, so `CheckResourcePermission` returned `$next($request)` without a permission check. Any authenticated + verified user could trigger the OpenAPI regeneration (which runs an artisan command server-side). Fix: `regenerateDocs => update` added to the map; `api-routes.update` added to `config/permission-resources.php` so the seeder creates the permission row.

- **SVG uploads blocked on logo + FileManager.** Both the admin logo uploader (`SettingsController::uploadLogo`) and the FileManager default MIME list accepted `image/svg+xml` and stored the file on the `public` disk. SVG can embed `<script>`, `onload` and foreignObject JavaScript; when a victim opens the direct `/storage/...` URL, the script executes in the app origin (stored XSS). Fix: logo validation now pins `mimes:png,jpg,jpeg,webp` + `dimensions:max_width=4096,max_height=4096`. `UploadFileRequest` keeps a `BLOCKED_MIMES` list (`image/svg+xml`, `image/svg`, `text/html`, `application/xhtml+xml`) that is stripped from the effective MIME list on every upload, regardless of what is stored in `file_manager.accepted_mimes`. `UpdateFileManagerSettingsRequest` rejects those MIME types at settings-save time via `Rule::notIn(...)` plus a `^[a-z0-9.+-]+/[a-z0-9.+-]+$` regex. The admin UI pickers (`MimePickerField`, `FileManagerTab`, `GeneralTab` logo input) no longer list SVG. `SettingsDefaultsQuery::fileManager()` also strips the blocked MIMEs from the stored list before sending the payload to the UI, so older installs whose seed included `image/svg+xml` no longer see it as a selected option.

- **Avatar rule tightened.** `UploadAvatarRequest::rules()` used to be `['required','image','max:2048']` — the `image` rule allows SVG and does not bound pixel dimensions, leaving the door open for polyglot files and decompression-bomb PNGs. New rule: `required | image | mimes:jpg,jpeg,png,webp | max:2048 | dimensions:max_width=4096,max_height=4096`.

- **`media-library.disk_name` now defaults to `local`.** The previous default was `public` — if the installer seeder failed, an admin flipped the FileManager disk toggle, or someone deployed without running the seeder, user-uploaded documents landed on a world-readable URL. The default is now `local` so missing configuration fails closed; the FileManager already streams downloads through `DownloadFileAction`, it never needed a public URL path.

- **`SESSION_ENCRYPT` + `SESSION_SECURE_COOKIE` default to `true`.** `config/session.php` had `'encrypt' => env('SESSION_ENCRYPT', false)` and `'secure' => env('SESSION_SECURE_COOKIE')` (null default). A deployment that forgot to set either env var would ship plaintext session payloads over an insecure cookie on HTTPS. Both defaults are now `true`; local dev continues to work because `.env.example` already sets both to `true` and Herd serves over HTTPS.

- **`SecurityHeaders` middleware now emits a baseline CSP.** The middleware already set X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy / HSTS, but no `Content-Security-Policy`. With two `v-html` sinks in the codebase (the Fortify 2FA QR SVG and the DataTable `column.render` escape hatch) a CSP meaningfully limits blast radius. The header is applied in non-local environments only — Vite HMR in local dev needs the dev-server origin on script/connect/style, which varies per developer, so enforcing a tight CSP there would just block normal work.

- **Scramble "Try It" disabled in production.** `config/scramble.php` shipped with `hide_try_it: false` and `try_it_credentials_policy: 'include'`, which in production handed any admin with `api-docs.read` an in-browser API tester that attached their session cookies to every request. Both values now branch on `APP_ENV === 'production'` (hidden + `omit` in prod, interactive in local/staging).

- **Passport access-token TTL shortened, scope catalogue seeded.** Access tokens were valid for 15 days, personal access tokens for 6 months. A leaked bearer token stayed usable for weeks. Defaults are now `access_token_minutes=60`, `refresh_token_days=14`, `personal_token_days=30`; the legacy `PASSPORT_TOKEN_DAYS` / `PASSPORT_PERSONAL_TOKEN_MONTHS` env keys still take precedence when set, so existing installs are not disturbed. `config/starter-kit.php` also ships an opt-in scope catalogue (`users.read`, `users.write`, `files.read`, `files.write`, `admin`) so `Passport::tokensCan()` is pre-wired; attach `middleware('scope:...')` to specific API routes when you are ready to enforce per-scope access.

- **API register / login now honour the `turnstile` middleware.** Cloudflare Turnstile was already wired for the browser auth forms via `FortifyServiceProvider` + `ValidateTurnstile`, but the API routes (`POST /api/v1/auth/register`, `POST /api/v1/auth/login`) only had the `throttle:5,1` limiter. An attacker could automate account registration at five accounts per IP per minute. Both routes now run through the existing `turnstile` middleware alias — when Turnstile is disabled in settings the middleware is a no-op, when it is enabled the API picks up the same `cf_turnstile_response` enforcement as the web forms.

#### Fixed

- **User domain events now fire on Create/Update/Delete.** `App\Domain\User\Actions\CreateUserAction`, `UpdateUserAction` and `DeleteUserAction` previously had their `UserCreated::dispatch(...)` / `UserUpdated::dispatch(...)` calls commented out or missing — listeners registered in `DomainServiceProvider` (e.g. the audit-log listener) never ran for user writes. `Create` and `Update` now dispatch when a change actually occurs (no-op updates do not fire `UserUpdated`); `Delete` captures the id/email before deletion and dispatches `UserDeleted` on success, matching the `Role*` action pattern.

- **Admin `users.show` route returned 500.** `routes/web/user-route.php` registered `Route::resource('users', UserController::class)`, which implicitly opened a `GET /users/{user}` route — but `UserController` never had a `show()` method, so any hit on that URL threw `BadMethodCallException`. The resource registration is now scoped with `->except(['show'])`; detail data remains available via the existing `GET /users/{user}/data` endpoint used by the admin UI.

- **Settings logo endpoints now return the `ApiResponse` envelope.** `POST /settings/logo` and `DELETE /settings/logo` in `App\Http\Controllers\Admin\SettingsController` used to return raw `response()->json([...])` / `response()->json(status: 204)`, breaking the "every JSON response carries `{ success, status, message, data }`" contract that the rest of the admin API follows. Both endpoints now go through `to_api(...)`. The frontend consumer (`GeneralTab.vue`) reads `json.data.logo_url`, which is unchanged.

- **`App\Policies\UserPolicy` gained a `delete` ability.** `DELETE /media/{media}` calls `Gate::authorize('delete', $media->model)` in `MediaUploadController`. For a media item owned by a `User`, the `delete` ability was undefined on `UserPolicy` (only `view` and `update` existed), so the Gate fell through to the default deny and returned 403 — even for the owner deleting their own avatar/file. The new `delete(User $actor, User $user)` method mirrors `update`: self is always allowed, otherwise the actor needs the `users.delete` permission.

- **`CheckResourcePermission` middleware: process-wide cache replaced with request-scoped cache.** The permission-existence lookup inside the middleware held its result in a `static $cached` variable. On long-lived workers (Laravel Octane, queue workers that keep the container warm across jobs) this cache never rebuilt, so newly-created permission rows were invisible until the worker restarted. Worse, inside the test suite the static survived across tests — `RefreshDatabase` truncated the `permissions` table between tests but the middleware kept reporting permission names seeded by an earlier test as still existing, producing intermittent 403s on routes that should have been permission-less. The cache is now stored via `app()->instance('check-permission.cache', ...)` — request-scoped in production, test-scoped under the testing container.

- **`UserFactory` seeds `two_factor_*` columns as `null` by default.** Eloquent strict mode (`Model::shouldBeStrict(! isProduction())`, set by `Lvntr\StarterKit\StarterKitServiceProvider`) throws "attribute [two_factor_secret] either does not exist or was not retrieved" when code reads those columns on a fresh factory instance (e.g. from `ProfileController` after `$this->actingAs(User::factory()->create())`). The factory now writes `two_factor_secret`, `two_factor_recovery_codes` and `two_factor_confirmed_at` as explicit `null`s so the in-memory model has all three attributes without a `->refresh()`.

- **`CreateUserAction` and `UpdateUserAction` now wrap the write + role sync in a transaction.** `User::create(...)` followed by `->syncRoles(...)` was running outside a transaction — if `syncRoles` failed (connection drop, permission cache invalidation, role-not-found race), the user row persisted with no roles, leaving inconsistent state in the admin list. Both actions now run inside `DB::transaction(...)`; the event dispatch happens after the transaction commits so listeners see a consistent state.

- **`MoveItemAction::wouldCreateCycle` no longer issues one SELECT per ancestor.** The method used to walk the folder tree by `FileFolder::find($parentId)` on every hop, so moving a folder with N ancestors produced N queries. For large trees this was both a perf footgun and a potential route for slow-query DoS. The ancestor map is now loaded once per call (single `SELECT id, parent_id WHERE owner_type=? AND owner_id=?`) and the walk happens in memory with a cycle-visited guard.

- **Folder create / rename / move now catch unique-constraint violations.** `CreateFolderAction`, `RenameFolderAction` and `MoveItemAction` check-then-act against `(owner_type, owner_id, parent_id, name)` uniqueness. Two concurrent requests could pass the existence check in lockstep and the second one would surface a raw `QueryException` (500) instead of a validation error. The race window is now closed — each action catches SQL-state `23000` (or MySQL 1062) and rethrows a localised `LogicException`, which the controllers already translate to a 422 with the `sk-file-manager.errors.duplicate_folder` message. The existing pre-check still handles `parent_id=NULL` (where the unique index does not enforce uniqueness on MySQL/SQLite because NULL is treated as distinct).

- **`UserDatatableQuery` now eager-loads `media`.** `UserResource::$appends` forces the `avatar_url` accessor, which calls `$user->getFirstMedia('avatar')`. With the datatable query eager-loading only `roles`, every row triggered a separate media lookup (N+1). `media` is now part of the eager load list; per-page rendering drops from `1 + n` queries to `2`.

- **`RoleController@data` and `@edit` now use a `RoleResource` instead of spreading `$role->toArray()`.** The spread was cheap to add but violated the project's "responses go through a Resource" convention and would silently broadcast any future sensitive column added to the `roles` table. The new `App\Http\Resources\Admin\Role\RoleResource` lists the intended fields explicitly (`id`, `name`, `display_name`, `group`, `sort_order`, `guard_name`, `seeded_permissions`, timestamps, + conditional `permissions` when loaded). Frontend payload shape is preserved.

- **`resources/js/pages/Admin/ApiRoutes/Index.vue`: external link now has `rel="noopener noreferrer"`.** The "Open API Docs" anchor used `target="_blank"` without the usual rel attributes. Consistent with the rest of the project.

- **Missing translations for the 2FA disable confirmation dialog.** `sk-setting.auth.two_factor_disable_title` and `sk-setting.auth.two_factor_disable_warning` were referenced from the Auth settings tab but not defined in either language file. Added for EN and TR.

#### Added

- **Passport key auto-generation for the API test suite.** `tests/Pest.php` now registers a `beforeEach` hook scoped to `tests/Feature/Api` that runs `passport:keys --force` when `storage/oauth-private.key` is missing. Fresh clones and CI runners no longer need `php artisan site:install` before the Passport-backed tests (`AuthTest`, `UserTest`) can pass — the old behaviour was an opaque `LogicException: Invalid key supplied` from `league/oauth2-server`.

- **`tests/Feature/Domain/User/UserEventsTest.php`.** Pins the event-dispatch contract introduced by the fix above — asserts that `UserCreated` fires on create, `UserUpdated` fires only when at least one tracked field changes, `UserDeleted` fires on successful delete, and that the self-deletion guard does not spuriously dispatch.

- **Logo upload/delete coverage in `tests/Feature/Admin/SettingsTest.php`.** Locks the `ApiResponse` envelope on `POST /settings/logo` (200 with `data.logo_url`) and the 204 contract on `DELETE /settings/logo`.

## 2026-04-18 -v.13.3.0

### Feature release — Cloudflare Turnstile, last-login tracking, file preview modals, shipped `validation.php`, and the `sk-*` translation namespace

A large release. Several independent additions plus one architectural shift on the translation layer.

#### Added

- **Cloudflare Turnstile captcha on the auth flows.** Login, register and password-reset forms now host a Turnstile widget (`resources/js/components/Auth/TurnstileWidget.vue`) and validate the token server-side. Ships with: a `turnstile` middleware alias backed by `App\Http\Middleware\ValidateTurnstile`, an `App\Rules\TurnstileRule` for ad-hoc validation, `App\Domain\Setting\DTOs\TurnstileSettingsDTO`, and a **Settings → Turnstile** admin tab to manage site key / secret key from the UI. Turn it on/off per installation; widgets short-circuit cleanly when the feature is disabled.

- **Last login tracking.** A new `App\Listeners\UpdateLastLogin` listener, wired to `Illuminate\Auth\Events\Login`, writes `last_login_at` and `last_login_ip` to the user on every successful sign-in. Visible on the user detail page and exposed as a sortable column on the users datatable.

- **Inactive user block on login.** `App\Providers\FortifyServiceProvider` now rejects the login attempt when the authenticated user's status is not `active`, returning a clear error message instead of starting a session. Suspending an account no longer requires deleting it.

- **`FormBuilder.trans(bool)`.** New fluent method available on every field builder (`FB.inputText()`, `FB.select()`, `FB.toggleSwitch()`, …). Controls whether the label is rendered as a translation key (default, `true`) or as a pre-resolved raw string (`false`). Useful when you want to compose a label from `trans('admin.example')` inside the script itself — normally this breaks because the form template would call `$t()` again on the already-translated text and fall back to the original string. With `.trans(false)` the template skips the second translation step. Default behaviour is unchanged; existing pages continue to work without any edit.

    ```ts
    FB.inputText().key('last_name'); // default — label → $t('validation.attributes.last_name')
    FB.inputText().key('x').label(trans('admin.example')).trans(false); // raw render, no second $t() pass
    ```

- **In-app file previews (lightbox + modal).** Uploaded files — in the file manager and in any `FB.fileUpload()` form field — no longer open in a new browser tab when you click the thumbnail or file-name. Images fly up in a **fullscreen lightbox** (Google-Drive style: blurred black backdrop, ESC to dismiss, name in the top-left). Non-image files (PDF, video, audio, text) open inside a **mime-aware dialog** that embeds the correct viewer (iframe / `<video>` / `<audio>`) and offers a "Download" button in the file manager and an "Open in new tab" escape hatch for unrecognised formats. The lightbox is a single global overlay registered next to `<AppDialog />` in `AdminLayout`; the modal is a `FilePreviewModal` component opened through the existing `useDialog` composable.

- **Categorized mime-type picker in File Manager settings.** **Settings → File Manager → Accepted file types** used to be a long multiselect dropdown. It is now a categorized card-checkbox grid (Images / Documents / Archive) where each option shows the matching file-type icon next to the label. Easier to scan, click target is the whole card, and the list is grouped rather than alphabetical.

- **Feature-toggle cards for "Video uploads" and "Audio uploads".** The two toggles in File Manager settings share the same card aesthetic as the mime picker — a tinted icon on the left, a bold label and a short description (e.g. "Allow MP4, WebM, MOV, MKV, AVI and OGG videos.") next to the switch on the right. Clicking anywhere on the card flips the toggle.

- **`lang/{en,tr}/validation.php` are now shipped with the kit.** Laravel's default rule messages plus the `attributes` and `custom` sections used by both the Laravel validator and by FormBuilder / DatatableBuilder, which auto-resolve a field's label via `validation.attributes.{key}` when `.label()` is not specified. Turkish messages follow the Laravel-Lang/lang conventions. Consumer apps can edit these files freely to adjust wording or add new attribute labels — no custom translation loader is involved; everything runs through Laravel's native translation system.

- **Role name localisation with a graceful fallback chain.** The role label shown in the admin topbar / sidebar (shared via Inertia `auth.role`) now resolves in three steps: first `roles.display_name[locale]` from the database; then the locale key under `config('permission-resources.display_names.roles.{name}.{locale}')`; and finally `Str::headline($role->name)` — so a freshly seeded role like `system_admin` shows as "System Admin" instead of the raw slug, even when nothing localised has been configured.

#### Changed — translations moved to the `sk-*` namespace

Every shipped translation file now has an `sk-` filename prefix: `sk-admin.php`, `sk-auth.php`, `sk-button.php`, `sk-datatable.php`, `sk-menu.php`, `sk-setting.php`, `sk-user.php`, `sk-attribute.php`, `sk-file-manager.php`, `sk-activity-log.php`, … All shipped Vue pages and PHP code now reference the new keys (`__('sk-button.save')` instead of `__('button.save')`). The goal: consumer apps are free to own the unprefixed namespace (`lang/en/admin.php` for their own dashboard strings, not a collision with the starter kit's menu items).

#### Removed

The pre-13.3 unprefixed stubs — `stubs/lang/{en,tr}/{admin,auth,button,common,datatable,enums,file-manager,message,pagination,passwords,validation}.php` (21 files) — are no longer shipped. No code path in the kit references them after the `sk-*` migration; keeping them in fresh installs only caused confusion. The **package-level `starter-kit::` namespace is untouched** — `__('starter-kit::admin.menu')` calls still resolve.

#### Fixed

- **Upload validation rejected `.ogg` video and `.avi` files even with "Video uploads" enabled.** The `allow_video=true` branch of the upload request only whitelisted `video/mp4`, `video/webm`, `video/quicktime` and `video/x-matroska`. Added `video/ogg`, `video/x-msvideo` and `video/avi`, and added the matching extension labels (`.OGV`, `.AVI`) to the "Allowed types" list shown in validation error messages.

- **Spurious `npm run build` warnings silenced.** Two noisy warnings have been scrubbed from production builds: (1) the "Sourcemap is likely to be incorrect" notices emitted by `@tailwindcss/vite` and `@inertiajs/vite` — both plugins skip sourcemap regeneration after their transform, the runtime is unaffected — are now filtered via a targeted Rollup `onwarn` hook in `vite.config.ts` (other warnings still pass through); (2) the `resolveDirective imported but never used` warning from the shipped `SkDatatable.vue` and `FileManager.vue` — PrimeVue's `v-tooltip` / `v-ripple` directives are now bound explicitly in the `<script setup>` block (`const vTooltip = Tooltip`) so the template compiles to a direct reference instead of a dynamic lookup.

#### Upgrading from 13.2.x

`sk:update` is hash-aware: files you have not modified are replaced with the new version; files you have modified are reported as `skipped` or `untracked` and left alone. Several 13.3 feature files — `SettingsController`, `SettingsDefaultsQuery`, `FortifyServiceProvider`, `HandleInertiaRequests`, `AppServiceProvider`, and the new FormRequest classes — will likely show up in that list and need attention.

1. Run `php artisan sk:update --dry-run` to see what is skipped/untracked.
2. If you have no local customisations in the `app/` layer, take the package version for everything:

    ```bash
    php artisan sk:update --force
    ```

3. Pull the new translation files manually (`sk:update` does not touch `lang/`):

    ```bash
    cp vendor/lvntr/laravel-starter-kit/stubs/lang/en/sk-*.php lang/en/
    cp vendor/lvntr/laravel-starter-kit/stubs/lang/tr/sk-*.php lang/tr/
    ```

4. If your `lang/en/` still contains `admin.php`, `auth.php`, … from a prior `sk:install`, they now linger as orphans. The package no longer references them; delete them after migrating your `__('admin.x')` calls to `__('sk-admin.x')`.
5. `npm run build` — the new `TurnstileWidget.vue` is shipped and imported by `Login/Register/ForgotPassword`. Fresh installs get it automatically. Existing installs missing the file will see the build fail with `Could not load resources/js/components/Auth/TurnstileWidget.vue`; `sk:update` should copy it (it is a new file, not a replacement), but if not, copy it from `vendor/lvntr/laravel-starter-kit/stubs/resources/js/components/Auth/TurnstileWidget.vue`.

---

## 2026-04-16 -v.13.2.9

### `npm run build` — lang JSON dual-import warning eliminated

Consumer projects were emitting two warnings on every `npm run build`:

```
(!) lang/php_en.json is dynamically imported by resources/js/app.ts but also statically imported by resources/js/app.ts, dynamic import will not move module into another chunk.
(!) lang/php_tr.json is dynamically imported ...
```

Cause: the `i18nVue` resolve callback in `resources/js/app.ts` held two separate `import.meta.glob('../../lang/*.json', ...)` calls for SSR and client — one with `eager: true` (static) and one without (dynamic). Vite analysed both branches statically, saw the same files imported in both static and dynamic form, and warned that the dynamic branch would not get its own chunk. The dynamic branch never produced any benefit because the files were already in the static bundle.

Collapsed to a single eager glob hoisted to the module scope, with a `Promise.resolve()` wrapper for the client branch:

```ts
const langs = import.meta.glob<Record<string, string>>('../../lang/*.json', { eager: true });
const resolveLang = (lang: string) => langs[`../../lang/php_${lang}.json`];
app.use(i18nVue, {
    resolve: ssr ? resolveLang : (lang: string) => Promise.resolve(resolveLang(lang)),
});
```

Lang JSON files are small (a few KB), so static bundling has negligible bundle-size impact — the warning is gone permanently while behaviour is unchanged.

---

## 2026-04-16 -v.13.2.8

### Cleaner fresh installs

Fresh installs no longer include leftover development-only files or noisy placeholder data.

- **`.env.example` cleanup** — duplicate `DB_*` entries and an old sample database name were removed. The file now keeps only generic placeholders such as `your_database` and `your_username`.
- **Frontend/install cleanup** — unnecessary development-only frontend tooling entries were removed so `npm install` starts from a cleaner baseline.
- **Less clutter in new projects** — stray assistant/tooling files that did not belong in a fresh application are no longer shipped.

---

## 2026-04-15 -v.13.2.7

### File manager upload — `crypto.randomUUID` fallback for HTTP contexts

The file manager upload composable generated a temporary id per queued file via `crypto.randomUUID()`. That API is only defined in a secure context — HTTPS or `localhost` — so any consumer running on a plain-HTTP dev domain (Herd's `.test`, a bare intranet IP, etc.) hit `TypeError: crypto.randomUUID is not a function` and the upload aborted before the first XHR fired.

`useFileManager` now routes through a local `generateTempId()` helper with a three-tier fallback:

1. `crypto.randomUUID()` when available (HTTPS / localhost)
2. `crypto.getRandomValues(new Uint8Array(16))` serialized as hex (available in every modern browser, no secure-context requirement)
3. `Date.now().toString(16)` + `Math.random().toString(16)` as last-resort

The tempId is only used to correlate a pending-upload row with its completion/error callback — no cryptographic strength needed, so the fallback is safe.

### Security headers — geolocation permitted from own origin

`SecurityHeaders` middleware `Permissions-Policy` was `geolocation=()` (fully denied). Changed to `geolocation=(self)` so first-party scripts can request geolocation when a feature legitimately needs it; third-party frames remain blocked.

---

## 2026-04-15 -v.13.2.6

### File manager validation messages — readable, localised, with original filename

Server-side rejection toasts now actually surface in the file manager UI and carry a friendly message instead of Laravel's raw `files.0 field must be a file of type: image/webp` text.

- **Toast group fix** — every `toast.add()` call in `FileManager.vue` now passes `group: 'bc'`. The shared `ToastComponent` is mounted with `group="bc"`, so previous calls without the key were silently dropped. Folder create/rename/delete/move and file upload toasts (success and error) all surface again.
- **Server error extraction** — the upload XHR previously read only `envelope.message` ("Validation error.") on a 422. The composable now walks `envelope.errors` and surfaces the first field-specific message, so the toast carries the actual reason.
- **Per-file friendly validation messages** — `UploadFileRequest` overrides `attributes()` and `messages()`. Each `files.{i}` slot is bound to the file's `getClientOriginalName()` (so the toast says `vacation.jpg yüklenemedi: …` instead of `files.0`). The mimetypes / max-size errors map to translation keys with a readable extension list (`İzinli tipler: WEBP, PDF, JPG, …`) and human-friendly size limit (`en fazla 10 MB`).
- **Translation keys** — `errors.upload_invalid_type`, `errors.upload_too_large`, `errors.upload_invalid_file` added in `lang/{en,tr}/file-manager.php`.

Two new feature tests cover the friendly messages: `it returns a friendly validation message with original filename when mime is rejected` and `… with size limit when file is too large`. The full file manager + install + publish suites stay green (22/22 + 11/11).

### Helpers reorganized — vendor-owned core, user-owned custom, publishable override

`to_api()` and `format_date()` (plus two new helpers — see below) now ship from the package vendor and are autoloaded automatically. End-user apps no longer keep a `to_api` copy under `app/`, removing the merge headache that used to come up every `sk:update`.

- **`vendor/lvntr/laravel-starter-kit/src/sk-helpers.php`** is the canonical location. It is registered via the package's `composer.json` `autoload.files`, so any consumer gets the helpers the moment they `composer require`.
- **`app/Helpers/custom.php`** is published into the consumer app on first install, added to the app's `composer.json` `autoload.files`, and is _never_ overwritten by `sk:update`. This is where user-specific global helpers live.
- **`app/helpers.php` is deprecated.** `sk:update` now compares the existing file's md5 against a list of known stock hashes; if it matches, the file is removed silently. If the user added their own functions, the file is left in place with a console warning so their code is preserved. The `composer.json` autoload entry is rewritten only when the file is actually gone — never silently breaking user code.
- **Two new helpers** — `definition($key, $value)` returns the matching definition record (object) from `DefinitionService`; `definitionLabel($key, $value)` returns its `label`. Useful for resolving enum-style values to display strings without re-fetching the definition list per call.

### `sk:publish --tag=helpers` — override the bundled helpers without forking

A new tag exposes `sk-helpers.php` to the publish command. After publishing, the file lands at `app/Helpers/sk-helpers.php` and the user can edit it freely.

The vendor file detects the published copy at autoload time and routes through it via `require_once`:

```php
$skPublishedHelpers = dirname(__DIR__, 4).'/app/Helpers/sk-helpers.php';
if (is_file($skPublishedHelpers) && realpath($skPublishedHelpers) !== realpath(__FILE__)) {
    require_once $skPublishedHelpers;

    return;
}
```

The realpath guard prevents self-recursion when the file is loaded as the published copy. No `composer.json` change is needed — composer autoload still triggers vendor's file, which then delegates to the user's. Deleting the published file reverts to the vendor implementation immediately.

The `sk:publish` interactive prompt gained a fourth option: **Global Helpers (sk-helpers.php)**.

---

## 2026-04-14 -v.13.2.4

### Type-safety sweep — zero `vue-tsc` and ESLint warnings

The starter kit source now passes `vue-tsc --noEmit` and `eslint 'resources/js/**/*.{ts,vue}'` with 0 errors / 0 warnings. No behavioural changes — purely type and lint cleanup.

- **tsconfig deduplication** — type-checking paths were simplified so the same UI sources are no longer scanned twice. This removes the duplicate errors that were confusing local development.
- **Vite `Components` plugin is single-source** — the `dirs` entry was trimmed to `resources/js/components` only; the package path is gone. The auto-generated `components.d.ts` now references source paths.
- **SkDatatable filter types widened** — `activeFilters` is now typed as a single `FilterValue` alias (`string | number | Date | (Date | null)[] | null`). DatePicker usages switched from `v-model` to `:model-value` + `@update:model-value` with narrow casts, so `select`, `select-button`, `date` and `daterange` filters each operate on their own typed value.
- **Tag icon / pagination i18n fixes** — the `:icon` expression closes off null leakage with `?? undefined`, and the `from/to/total` params passed to `datatable.records_info` are now `String(... ?? 0)` to match the expected `string` i18n argument type.
- **`SharedPageProps` index signature** — `[key: string]: unknown` added so the interface satisfies Inertia's `PageProps` constraint. `useCan()` now compiles cleanly under `usePage<SharedPageProps>()`.
- **`env.d.ts` auth shape aligned with runtime** — Inertia `sharedPageProps.auth` now carries `{ user, role, role_names, permissions }`; AdminHeader's `page.props.auth?.role` and similar reads resolve against correct types. `appEnv`, `appDebug`, `locale`, `availableLocales` are also typed on the shared props.
- **Small prop / cast fixes** — `RoleForm.vue` calls Wayfinder as `update.url({ id })` (narrowing the optional `id`), `Settings/Index.vue` adds `logo_url: string | null` to the `general` type, `Dashboard/Index.vue` greets by the real field (`user?.first_name` instead of a non-existent `user?.name`), and the redundant `preserveScroll: true` option was dropped from `router.reload()` calls — Inertia v3 already preserves scroll and state on `reload()` by default.
- **ESLint warnings** — the `v-html` usage inside `SkDatatable` is marked with a reasoned `eslint-disable-next-line` (the render string is author-defined and `escapeHtml` is provided). `Breadcrumb.rootLabel`, `FileGrid.emptyLabel` and `SkTag.{value,icon,color,severity}` now have `withDefaults` fallbacks.

No action required for existing installs — changes are purely type/lint level.

## 2026-04-14 -v.13.2.3

### Installer DX — AST-based injection, bootstrap helper, preset-aware guidance

A round of installer and upgrade ergonomics that make `composer require lvntr/laravel-starter-kit` on a fresh Laravel safer and less invasive.

- **AST-based config injection** — `sk:install` now edits `config/app.php`, `config/filesystems.php` and `config/media-library.php` via `nikic/php-parser` with format-preserving pretty printing. Regex-based patching is gone; the injection is tolerant of different Laravel config formats and fully idempotent (re-running `sk:install` is a no-op once injected).
- **Bootstrap helper cleanup** — middleware and exception wiring now flows through a single shared bootstrap helper, making installation updates more predictable.
- **`bootstrap/app.php` is no longer overwritten** — the stub copy is removed. Instead the installer **AST-injects three lines** into the user's existing Laravel default file: `api: __DIR__.'/../routes/api.php'` inside `withRouting(...)`, plus `Bootstrap::middleware(...)` / `Bootstrap::exceptions(...)` calls inside the two closures. User-added middleware, trusted proxies, custom exception reporters etc. are preserved.
- **`bootstrap/providers.php` is no longer overwritten** — the installer appends `DomainServiceProvider`, `FortifyServiceProvider`, `SettingsServiceProvider` to the array (idempotent, skips any already registered), leaving the user's existing entries untouched.
- **`package.json` JSON-merge** — instead of blind overwrite, the installer merges: stub versions win for shared dependencies, but any user-added deps, scripts, workspaces or root-level keys survive.
- **First-install detection for lang files** — `lang/*` is still preserved on re-install (so customisations aren't lost), but on a genuine first install (no hash registry) the installer now force-copies lang stubs so fresh projects don't inherit sparse Laravel defaults while the starter kit UI expects richer keys.
- **Dead code removed** — old `IdentityType` and `YesNo` enums that are no longer part of the active flow were removed from fresh installs and update paths.
- **IdeHelper cleanup** — `AppServiceProvider` no longer carries an unnecessary `class_exists(IdeHelperServiceProvider::class)` guard in fresh installs.
- **Explicit `nikic/php-parser ^5.0` requirement** — was transitively available via Tinker, now a direct package dep.
- **"Bare Laravel" install guidance** — README (EN/TR) and [install.md](https://starter-kit.lvntr.dev/docs/install) / [install.tr.md](https://starter-kit.lvntr.dev/docs/install) open with a warning: do **not** run `install:inertia`, `install:api`, Breeze, Jetstream or similar presets before installing the starter kit — they scaffold controllers, routes, pages and layouts that this kit also ships, and the installer cannot detect them, leaving orphan dead code.
- **Tests** — 12 new `InstallCommandTest` cases cover AST config injection (all three files), idempotency, format/comment preservation, `package.json` merge, first-install detection, bootstrap app/providers AST injection with user-code preservation. Total installer-related suite 20/20 green.

No action required for existing installs — all installer-side changes are backwards-compatible and gated on first-install detection or idempotency guards.

## 2026-04-14 -v.13.2.2

### FileManager — pluggable contexts via `ContextRegistry`

The FileManager is no longer limited to `user` / `global`. Any Eloquent model can own a folder tree with **zero service-provider wiring**.

- **New `ContextRegistry` service** (`app/Domain/FileManager/Support/`) resolves a context key in three steps: explicit `register()` → Laravel morph-map alias → `App\Models\{Studly(key)}` convention fallback. Unknown keys still return 422 via validation.
- **Zero-config custom contexts** — a model class + a matching policy (`view` / `update`) is all that's needed:
    ```vue
    <FileManager context="vehicle" :context-id="vehicle.id" height="100%" />
    ```
- **`global` baked into the registry** — the old registration in `AppServiceProvider::boot()` moved into `ContextRegistry`'s constructor, so adopting the starter kit no longer requires any boot-time setup for FM. `AppServiceProvider` only binds the singleton now.
- **`user` is fully auto-resolved** — via the `App\Models\User` convention plus the new shipped `app/Policies/UserPolicy.php` (self-access + `users.read` / `users.update` admin gate). The built-in registrations for `user` were removed in favour of policy-driven auth.
- **Default authorizer with self-match short-circuit** — auto-resolved contexts automatically allow an actor to manage their own record (actor IS owner). Other requests delegate to Laravel policies: `can('view', $owner)` for reads, `can('update', $owner)` for writes.
- **MorphMap-aware storage** — `FileManagerContextDTO` now stores `ownerType` via `$owner->getMorphClass()` so queries and path generation work even when the model has a morph-map alias.
- **Runtime-driven validation** — `FileManagerRequest` replaced the hard-coded `in:user,global` rule with a closure that queries `ContextRegistry` at runtime. Adding a new context no longer touches any Request file; `context_id` is only required when the registered path contains `{id}`.
- **Frontend type loosens for custom keys** — `FileManagerContext` is now `'user' | 'global' | (string & {})`, so calling `<FileManager context="vehicle" />` stays fully type-checked without losing autocomplete on the built-ins.
- **Upload resilience** — `UploadFileRequest` now falls back to a sensible MIME list (image / pdf / office / text) when `file_manager.accepted_mimes` isn't seeded yet, so a fresh install never hits the "file must be of type: ." 422.
- **Tests** — new `CustomContextTest` exercises explicit registration, path override, folder listings, unknown-context rejection and morph-map auto-resolution. Total 26/26 FileManager tests green.
- **Docs** — [file-manager.md](https://starter-kit.lvntr.dev/docs/file-manager) picked up a "Custom contexts" chapter with resolution order, zero-config walkthrough, `VehiclePolicy` example, contract table and override guidance.

## 2026-04-14 -v.13.2.1

### FileManager — UX polish & follow-ups

A batch of refinements landed on top of the initial 13.2.0 release, driven by real usage:

- **Preview modal** — tile click (for files) or context **Open** now opens a 90vw modal with inline preview for images, PDF, video, audio and text; non-previewable types fall back to an "Open in new tab" + "Download" pair.
- **Per-tile upload progress** — uploads now stream per-file via XHR with a progress bar drawn on an optimistic placeholder tile; failed uploads show a dismissable error tile while successful ones slot in when the list refreshes. The toolbar Upload button also spins during the batch.
- **Drag-and-drop move** — tiles are `draggable`; dropping onto a folder tile moves the whole selection there. External file-drag is detected via the `Files` data-transfer type, so internal drags no longer accidentally trigger the upload overlay.
- **Move modal with folder tree** — new Move action in both folder & file context menus opens a dialog with a `FolderTree` picker. Works for both single and bulk selections.
- **Busy overlay (modal card)** — Delete / Move / Rename operations now paint a white modal card over the FileManager area with a spinner, title, description and — for bulk ops — a live "N items remaining" counter plus a **Stop** button that cancels the remaining iterations.
- **Always-visible selection checkbox** — each folder / file tile has a top-right checkbox (primary-filled when selected, outline-on-hover when not). Plain click on folders just selects; **double-click opens**. File tiles still single-click-to-preview. The old 3-dot menus on tiles are gone — right-click is the single entry point.
- **Right-click no longer forces selection** — opening the context menu on an unselected tile keeps the existing selection untouched; bulk operations apply only when the right-clicked item is already in the selection.
- **Keyboard shortcuts** — `Ctrl/Cmd + A` selects all items in the current folder, `Delete` / `Backspace` deletes the selection (with confirmation), `Esc` clears the selection. All shortcuts are guarded so they never fire while typing in an input or with a dialog open.
- **Breadcrumb redesign** — replaced the PrimeVue breadcrumb with chip/pill crumbs, separated by chevrons, and moved below the info bar. Long folder names are truncated with `…` (configurable `maxChars`, default 18). Full path stays accessible as a `title` tooltip.
- **Current-folder header + back button** — removed the left folder tree (still used inside the Move picker). The main area now shows the current folder name with an icon, plus a `←` button when not at the root.
- **Empty-folder illustration** — empty folders render a large outlined folder SVG, a heading, and two-line hints (`Upload` / `New Folder`), replacing the plain "This folder is empty" line.
- **Aggregate info-bar stats** — the file count + total size shown in the info bar now walks the entire subtree of the current folder, not just its immediate files.
- **Download across disks** — `DownloadFileAction` now uses `Storage::disk($media->disk)->download(...)` so the force-download route works for local, S3 and DigitalOcean Spaces alike.
- **Context menu restyle** — white rounded card, larger item padding, separator before destructive **Delete** in both folder and file menus.
- **Sort direction tooltip** — the asc/desc toggle now has a dynamic PrimeVue tooltip ("Ascending · click for descending" / TR equivalent). As a side effect, the `Tooltip` directive is now globally registered in `app.ts`.
- **Footer credit** — `AdminFooter` shows _Crafted with **Lvntr Starter Kit**_ linking to lvntr.dev.

See [file-manager.md](https://starter-kit.lvntr.dev/docs/file-manager) for the refreshed usage guide, props and composable exports.

## 2026-04-14 -v.13.2.0

### FileManager — file management module

A new **FileManager** module shipped: a Windows Explorer-style UI delivering full file management for user-scoped or global files.

- **Nested folders** — create, rename, move, cascade delete
- **Multi-file upload** — drag & drop or button
- **Selection** — single click, `Ctrl/Cmd + click`, rubber-band drag for bulk
- **Bulk delete** — toolbar button or right-click on selected items
- **Sort** — by name / size / date + asc/desc
- **Type-aware previews** — image thumbnails + color-coded icons for PDF/Word/Excel/Video/Audio/Archive
- **Info bar** — current folder file count and aggregate size
- **Context menus** — separate actions for folder / file / empty area (New Folder, Upload, Select All, Refresh)

Added pages: **Files** in the main sidebar, **Files** tab on `Admin > Users > Edit`. Max upload size, accepted MIME types and video/audio toggles are configurable under `Admin > Settings > File Manager`.

Storage: `user/{id}/files/{uuid}/...` and `global/files/{uuid}/...` — folder moves are metadata-only, files never move on disk.

See [file-manager.md](https://starter-kit.lvntr.dev/docs/file-manager) for usage and API details.

## 2026-04-13 -v.13.1.10

### FormBuilder — stale form reset fix

Fixed a bug where `FB`-generated forms could silently reset to stale remote data after an Inertia `back()` navigation or any `page.props` refresh that caused `formConfig` to be recomputed. The internal `SkForm` now shallow-compares the new derived defaults against the previous ones and skips the reset when the values are identical, preserving in-progress user edits.

Affected: any form built with `FB` whose config depends on `page.props` (e.g. conditional `isFieldsLocked`, `isSelf`, auth-based field visibility). No API change — existing forms benefit automatically.

## 2026-04-13 -v.13.1.8

### FormBuilder — ColorSelector output format

`FB.colorSelector()` now supports configurable output formats via `.format()` and `.defaultTone()`:

- `format('name')` _(default)_ → stores `"blue"`
- `format('name-tone')` → stores `"blue-500"`
- `format('hex')` → stores `"#3b82f6"`

A clickable tone selector is rendered below the dropdown for `'name-tone'` and `'hex'` formats, with the resolved value shown next to the tone pills. When the initial model value is a hex string, the component reverse-looks it up against the Tailwind palette to restore the matching color + tone.

See [formbuilder.md](https://starter-kit.lvntr.dev/docs/formbuilder#colorselector-field-api) for details.
