331 lines
20 KiB
Markdown
331 lines
20 KiB
Markdown
|
|
# PromptVault — Application Specification
|
||
|
|
|
||
|
|
> **Status:** COMPLETE (source of truth for this application)
|
||
|
|
> **Last updated:** 2026-10-26
|
||
|
|
|
||
|
|
## 1. Overview
|
||
|
|
|
||
|
|
PromptVault is a **local-first prompt library for LLM workflows**. It lets a
|
||
|
|
developer store, organize, search, and reuse reusable prompt templates, with
|
||
|
|
`{{variable}}` placeholders that can be filled in and rendered on demand.
|
||
|
|
Everything runs locally: PHP + SQLite on the backend, Vue 3 SPA on the front
|
||
|
|
end. No accounts, no cloud, no external LLM calls.
|
||
|
|
|
||
|
|
### 1.1 Goals
|
||
|
|
- CRUD for prompts with name, description, body, and tags.
|
||
|
|
- Extract `{{variables}}` from a prompt body; render the prompt with provided
|
||
|
|
values; strict about missing values.
|
||
|
|
- Search (full-text-ish substring), filter by tag and by favorite, sort by a
|
||
|
|
whitelisted set of fields, pagination.
|
||
|
|
- Track per-prompt usage (`usage_count`, `last_used_at`) incremented whenever a
|
||
|
|
prompt is rendered.
|
||
|
|
- Duplicate, favorite-toggle, and delete prompts.
|
||
|
|
- Fast, keyboard-friendly single-page UI.
|
||
|
|
|
||
|
|
### 1.2 Non-goals (v1)
|
||
|
|
- Authentication / multi-user support (single-user local tool).
|
||
|
|
- Calling LLM providers; rendering is purely client-side text interpolation.
|
||
|
|
- Import/export (CSV/JSON), sharing, comments, versioning.
|
||
|
|
- Soft deletes, audit logs, i18n.
|
||
|
|
|
||
|
|
## 2. Tech stack (decided)
|
||
|
|
|
||
|
|
| Layer | Choice | Notes |
|
||
|
|
|------------|---------------------------------------------------------------|-------|
|
||
|
|
| Language | PHP **8.4** (min 8.3) | |
|
||
|
|
| Framework | **Laravel** (current stable, 12.x/13.x skeleton) | Slim skeleton, `bootstrap/app.php` wiring |
|
||
|
|
| ORM | **Eloquent** | |
|
||
|
|
| Database | **SQLite 3** (`pdo_sqlite`), `database/database.sqlite` | Laravel's default |
|
||
|
|
| Frontend | **Vue 3.5** (`<script setup>`) + **Inertia.js 3** (`@inertiajs/vue3`) | SPA served by Laravel |
|
||
|
|
| Bundler | **Vite 8** + `laravel-vite-plugin` | |
|
||
|
|
| Styling | **Tailwind CSS 4** (`@tailwindcss/vite`) | No component libraries |
|
||
|
|
| PHP tests | **PHPUnit 12** (shipped with skeleton) + **Xdebug** for coverage | `RefreshDatabase` |
|
||
|
|
| JS tests | **Vitest 5** + `@vue/test-utils` + **happy-dom** | v8 coverage provider |
|
||
|
|
| HTTP client (JS) | `axios` (skeleton default, used by Inertia) | |
|
||
|
|
|
||
|
|
All versions are "latest stable at scaffold time"; the exact resolved versions
|
||
|
|
live in `composer.json` / `package.json` (vendor & node_modules excluded from
|
||
|
|
coverage, per guidelines).
|
||
|
|
|
||
|
|
## 3. Architecture
|
||
|
|
|
||
|
|
**Principles:** SOLID, KISS, CQRS-lite (separate command/write models from
|
||
|
|
query/read models), Law of Demeter (each object talks only to its immediate
|
||
|
|
neighbors: Controller → Service/Request → Model; Vue component → its own props
|
||
|
|
→ Inertia).
|
||
|
|
|
||
|
|
### 3.1 CQRS-lite layout
|
||
|
|
|
||
|
|
Writes go through **command DTOs** handled by `PromptService`; reads go through
|
||
|
|
**query DTOs** handled by `PromptCatalog`. Single-entity actions (delete,
|
||
|
|
duplicate, favorite, usage) take the model directly — KISS over ceremony.
|
||
|
|
|
||
|
|
```
|
||
|
|
app/
|
||
|
|
Actions/Prompts/
|
||
|
|
StorePrompt.php # command DTO: name, description, body, tags[]
|
||
|
|
UpdatePrompt.php # command DTO: same shape, applied to a Prompt
|
||
|
|
ListPrompts.php # query DTO: search, tag, favorites, sort, direction
|
||
|
|
Services/
|
||
|
|
PromptService.php # store, update, delete, duplicate, toggleFavorite, recordUsage
|
||
|
|
PromptCatalog.php # list(ListPrompts) -> LengthAwarePaginator; get(id) -> Prompt
|
||
|
|
PromptRenderer.php # pure: extractVariables(), render() (strict)
|
||
|
|
Exceptions/
|
||
|
|
PromptRenderException.php# carries missingVariables[]
|
||
|
|
Http/
|
||
|
|
Controllers/PromptController.php # thin: bind request -> DTO -> service/catalog
|
||
|
|
Requests/
|
||
|
|
StorePromptRequest.php
|
||
|
|
UpdatePromptRequest.php
|
||
|
|
Models/
|
||
|
|
Prompt.php # belongsToMany Tag
|
||
|
|
Tag.php # belongsToMany Prompt
|
||
|
|
User.php # skeleton default, unused (kept for parity)
|
||
|
|
database/
|
||
|
|
migrations/…_create_tags_table.php
|
||
|
|
migrations/…_create_prompts_table.php # prompts + prompt_tag pivot
|
||
|
|
seeders/PromptSeeder.php # demo tags + 4 sample prompts
|
||
|
|
seeders/DatabaseSeeder.php # calls PromptSeeder
|
||
|
|
resources/js/
|
||
|
|
app.js # entry wiring (excluded from coverage: wiring only)
|
||
|
|
Layouts/AppLayout.vue # shell, nav
|
||
|
|
Pages/Prompts/Index.vue # table + search/filter/sort + pagination + row actions
|
||
|
|
Pages/Prompts/Create.vue # PromptForm -> POST
|
||
|
|
Pages/Prompts/Edit.vue # PromptForm -> PUT
|
||
|
|
Pages/Prompts/Show.vue # details, RenderPreview, usage stats
|
||
|
|
Components/
|
||
|
|
PromptForm.vue # shared create/edit form
|
||
|
|
RenderPreview.vue # variable inputs + live rendered preview
|
||
|
|
ConfirmDialog.vue # confirm-before-danger
|
||
|
|
TagPill.vue # tag chip (clickable in filter contexts)
|
||
|
|
tests/
|
||
|
|
Unit/ # renderer, models
|
||
|
|
Feature/ # every route, happy + sad paths
|
||
|
|
js/ # Vitest: components + pages (Inertia mocked)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 Dependency direction
|
||
|
|
`routes/web.php` → `PromptController` → (`StorePromptRequest` | DTO) →
|
||
|
|
`PromptService` / `PromptCatalog` → Eloquent models → SQLite.
|
||
|
|
No model calls a service; no controller touches the DB directly; the renderer
|
||
|
|
is a pure function with zero framework imports (SRP/DIP-friendly, trivially
|
||
|
|
unit-testable).
|
||
|
|
|
||
|
|
## 4. Data model
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE tags (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
name TEXT NOT NULL UNIQUE, -- max 40, normalized lowercase/trimmed
|
||
|
|
created_at TEXT, updated_at TEXT
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE prompts (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
name TEXT NOT NULL, -- max 120
|
||
|
|
description TEXT, -- max 500, nullable
|
||
|
|
body TEXT NOT NULL, -- prompt template, may contain {{vars}}
|
||
|
|
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||
|
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||
|
|
last_used_at TEXT, -- nullable timestamp
|
||
|
|
created_at TEXT, updated_at TEXT
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE prompt_tag (
|
||
|
|
prompt_id INTEGER NOT NULL REFERENCES prompts(id) ON DELETE CASCADE,
|
||
|
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||
|
|
PRIMARY KEY (prompt_id, tag_id)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
Eloquent casts: `is_favorite => boolean`, `usage_count => integer`,
|
||
|
|
`last_used_at => datetime`. `tags()` is `belongsToMany` with alphabetical
|
||
|
|
ordering.
|
||
|
|
|
||
|
|
## 5. HTTP surface
|
||
|
|
|
||
|
|
All pages are Inertia (HTML); `/use` is JSON. No auth middleware.
|
||
|
|
|
||
|
|
| Method | Route | Page/JSON | Behavior |
|
||
|
|
|--------|--------------------------------|-----------|----------|
|
||
|
|
| GET | `/` | redirect | 302 → `/prompts` |
|
||
|
|
| GET | `/prompts` | Index | List with `search`, `tag`, `favorites`, `sort`, `direction`, `page` query params |
|
||
|
|
| GET | `/prompts/create` | Create | Empty form |
|
||
|
|
| POST | `/prompts` | redirect | Validate → `PromptService::store` → redirect to new prompt |
|
||
|
|
| GET | `/prompts/{prompt}` | Show | Prompt + extracted variables; 404 if missing |
|
||
|
|
| GET | `/prompts/{prompt}/edit` | Edit | Form pre-filled |
|
||
|
|
| PUT | `/prompts/{prompt}` | redirect | Validate → `update` → redirect back to show |
|
||
|
|
| DELETE | `/prompts/{prompt}` | redirect | Delete → redirect to index |
|
||
|
|
| POST | `/prompts/{prompt}/duplicate` | redirect | Copy → redirect to new prompt |
|
||
|
|
| POST | `/prompts/{prompt}/toggle-favorite` | redirect | Flip `is_favorite` → redirect back |
|
||
|
|
| POST | `/prompts/{prompt}/use` | **JSON** | Body `{"values":{"var":"…"}}` → `{"rendered":"…"}`; on missing vars → `422 {"message":"Missing variables","missing":[…]}`; on success increments usage |
|
||
|
|
|
||
|
|
### 5.1 Query param contract (`GET /prompts`)
|
||
|
|
- `search` — substring match (case-insensitive) across `name`, `description`, `body`.
|
||
|
|
- `tag` — tag **name**; unknown tag ⇒ empty result set (no error).
|
||
|
|
- `favorites` — `"1"` ⇒ only favorites.
|
||
|
|
- `sort` — whitelist: `created_at` (default), `name`, `usage_count`, `last_used_at`, `updated_at`. Anything else ⇒ `created_at`.
|
||
|
|
- `direction` — `asc` | `desc` (default `desc`; `name` sorts `asc` by default).
|
||
|
|
- `page` — 1-based; **10 per page**.
|
||
|
|
- `last_used_at` with `asc`/`desc` keeps `NULL`s last via
|
||
|
|
`orderByNulls`-equivalent (`whereNotNull`-aware ordering).
|
||
|
|
|
||
|
|
### 5.2 Validation rules
|
||
|
|
| Field | Store | Update |
|
||
|
|
|---------------|-------|--------|
|
||
|
|
| `name` | required, string, max:120 | required, string, max:120 |
|
||
|
|
| `description` | nullable, string, max:500 | nullable, string, max:500 |
|
||
|
|
| `body` | required, string | required, string |
|
||
|
|
| `tags` | nullable, array; each: string, max:40 | nullable, array; each: string, max:40 |
|
||
|
|
|
||
|
|
> `tags.*` entries: non-strings are rejected (validation); empty / whitespace-only
|
||
|
|
> strings are **accepted** and stripped during normalization (Laravel's `string`
|
||
|
|
> rule would wrongly reject them — see decision log #14).
|
||
|
|
|
||
|
|
Tag normalization: trim → strip empty → lowercase → unique → create-or-reuse by
|
||
|
|
name → `sync`.
|
||
|
|
|
||
|
|
## 6. Prompt renderer semantics (decided)
|
||
|
|
|
||
|
|
- Variable syntax: `{{ name }}` — regex `/\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/`.
|
||
|
|
Anything else (`{{}}`, `{{a b}}`, `{{a!b}}`) is **literal text**, not a variable.
|
||
|
|
- `extractVariables(string $body): string[]` — unique, first-occurrence order.
|
||
|
|
- `render(string $body, array $values): string` — replaces every occurrence.
|
||
|
|
**Strict mode:** if any variable lacks a string value (missing, or non-string
|
||
|
|
value), throws `PromptRenderException` with the full ordered list of missing
|
||
|
|
variable names. Unknown/extra values in `$values` are ignored.
|
||
|
|
- Values are inserted verbatim (no escaping; this is plain-text templating, not
|
||
|
|
HTML generation).
|
||
|
|
|
||
|
|
## 7. Service behavior (decided)
|
||
|
|
|
||
|
|
- `store(StorePrompt): Prompt` — insert + sync tags.
|
||
|
|
- `update(UpdatePrompt, Prompt): Prompt` — set fields + sync tags; usage stats
|
||
|
|
untouched.
|
||
|
|
- `delete(Prompt): void` — hard delete (pivot cascades).
|
||
|
|
- `duplicate(Prompt): Prompt` — new row: `name = "{name} (copy)"` (appending
|
||
|
|
` (copy)` repeatedly until unique, e.g. `"X (copy) (copy)"`), same
|
||
|
|
description/body/tags, `is_favorite = false`, `usage_count = 0`,
|
||
|
|
`last_used_at = null`.
|
||
|
|
- `toggleFavorite(Prompt): Prompt` — flip boolean.
|
||
|
|
- `recordUsage(Prompt): Prompt` — `usage_count += 1`, `last_used_at = now`.
|
||
|
|
- `PromptCatalog::list(ListPrompts)` — applies §5.1, eager-loads `tags`,
|
||
|
|
paginates 10.
|
||
|
|
- Single-prompt reads (show/edit/use) use route model binding + `loadMissing('tags')`; no separate `PromptCatalog::get()` (decision #15).
|
||
|
|
|
||
|
|
## 8. Frontend decisions
|
||
|
|
|
||
|
|
- **Inertia 2 + Vue 3 `<script setup>`**; no Pinia (prop-driven pages, KISS).
|
||
|
|
- **AppLayout**: top bar with brand "PromptVault", nav (Prompts, + New
|
||
|
|
Prompt), max-width container.
|
||
|
|
- **Index**: search input (debounced, updates `?search`), tag dropdown (from
|
||
|
|
`tags` prop), favorites toggle, sort/direction select, table
|
||
|
|
(Name / Tags / Usage / Last used / Favorite star), row actions: Show,
|
||
|
|
Duplicate, Favorite, Delete (ConfirmDialog). Pagination prev/next.
|
||
|
|
- **Show**: metadata, tags, body in `<pre>`, variable list, **RenderPreview**
|
||
|
|
(one input per variable + live preview + "Copy rendered" button + "Use
|
||
|
|
prompt" button that POSTs values and shows the rendered result and bumps
|
||
|
|
usage), danger zone (Duplicate / Delete).
|
||
|
|
- **Create/Edit**: shared **PromptForm** (name, description, body, tag input
|
||
|
|
with comma separation + suggestion chips), server error display, submit via
|
||
|
|
Inertia `useForm`.
|
||
|
|
- **RenderPreview** computes the preview client-side using the same regex
|
||
|
|
rules as the server (documented as a mirrored contract).
|
||
|
|
- Styling: Tailwind utility classes only; dark-agnostic neutral palette; no
|
||
|
|
external fonts/icons (unicode glyphs only, e.g. ★ ✎ 🗑 — keep it dependency-free).
|
||
|
|
|
||
|
|
## 9. Testing strategy (decided)
|
||
|
|
|
||
|
|
Target: **≥ 95% line coverage of application code** with:
|
||
|
|
- PHP: `app/**` (PHPUnit 12 + Xdebug).
|
||
|
|
- JS: `resources/js/**` (Vitest 5 + v8 provider), **excluding** `app.js`
|
||
|
|
(framework wiring with no logic — disclosed exclusion).
|
||
|
|
- `vendor/` and `node_modules/` excluded per guidelines (they never are).
|
||
|
|
|
||
|
|
PHP suites (PHPUnit 12; `inertiajs/inertia-laravel` 3 ships the built-in
|
||
|
|
`assertInertia` macro, no extra package needed):
|
||
|
|
- `Unit/PromptRendererTest.php` — extraction edge cases (none, repeats,
|
||
|
|
spacing, invalid tokens, order), render success, strict missing, extra
|
||
|
|
values ignored, verbatim insertion.
|
||
|
|
- `Unit/ListPromptsTest.php` — sort/direction normalization + fallbacks.
|
||
|
|
- `Unit/ModelsTest.php` — Prompt/Tag relations, casts, tag ordering, User
|
||
|
|
smoke (skeleton model).
|
||
|
|
- `Feature/HomeTest.php` — `GET /` → 302 → `/prompts`.
|
||
|
|
- `Feature/Prompts/PromptIndexTest.php` — default list, search, tag filter
|
||
|
|
(known + unknown), favorites, each sort + direction, `last_used_at`
|
||
|
|
NULLs-last in both directions, invalid sort/direction fallback, pagination
|
||
|
|
size.
|
||
|
|
- `Feature/Prompts/PromptStoreUpdateTest.php` — create page render (empty
|
||
|
|
form + suggested tags; old-input reflash after failed store), valid store
|
||
|
|
(incl. tag normalization/create/reuse), each invalid case, valid update,
|
||
|
|
invalid update, tag sync on update (add + remove).
|
||
|
|
- `Feature/Prompts/PromptShowDeleteTest.php` — show found/404, edit page,
|
||
|
|
delete/404.
|
||
|
|
- `Feature/Prompts/PromptActionsTest.php` — duplicate (copy name, tag copy,
|
||
|
|
unique-name collision, stats reset), toggle favorite (on↔off), `use`
|
||
|
|
success (payload rendered, counters updated), `use` missing variables
|
||
|
|
(422 + `missing[]`), `use` non-string value (422), `use` with no `values`
|
||
|
|
(validation 422), `use` 404.
|
||
|
|
|
||
|
|
JS suites (`tests/js/` — 64 tests across 10 files, all green; Inertia mocked
|
||
|
|
with a shared stub, `tests/js/stubs/inertia.js`: `Link`, `Head`, `router`,
|
||
|
|
`useForm`, `usePage`, plus `inertiaMock` helpers `failNext` (rejected
|
||
|
|
submission: seeds `form.errors` + `page.props.errors`, fires `onError`) and
|
||
|
|
`deferSubmissions` (assert `processing === true` states); `resources/js/app.js`
|
||
|
|
entry wiring excluded from coverage, disclosed):
|
||
|
|
- `lib/format.test.js` — empty → em-dash, invalid → raw pass-through,
|
||
|
|
valid → localized format.
|
||
|
|
- `Components/TagPill.test.js` — plain span vs. filterable Inertia link
|
||
|
|
(encoded filter href).
|
||
|
|
- `Components/ConfirmDialog.test.js` — render states, confirm/cancel emits,
|
||
|
|
Escape key handling, listener attach/detach.
|
||
|
|
- `Components/PromptForm.test.js` — field render, tag chip add/remove
|
||
|
|
(Enter/comma/Backspace/blur), duplicate/empty rejection, suggestion chips,
|
||
|
|
server-error display (array + string), POST/PUT payload transform,
|
||
|
|
processing state, `onError`/`clearErrors`, cancel link.
|
||
|
|
- `Components/RenderPreview.test.js` — variable input generation, live preview
|
||
|
|
substitution, empty-state, `use` emit with entered values, busy state,
|
||
|
|
clipboard copy success/failure.
|
||
|
|
- `Layouts/AppLayout.test.js` — brand/nav/footer + slot render.
|
||
|
|
- `Pages/Prompts/Index.test.js` — rows from props, empty state, search/submit
|
||
|
|
form, tag/favorites/sort/direction filters, favorite & duplicate actions,
|
||
|
|
delete confirm + cancel (+ no-target guard), pagination incl. boundaries.
|
||
|
|
- `Pages/Prompts/Show.test.js` — renders prompt + variables, favorite/duplicate
|
||
|
|
/delete flows, copy template (success/failure), "use" flow success, 422 with
|
||
|
|
`missing[]` list, unreadable error body, network failure.
|
||
|
|
- `Pages/Prompts/Create.test.js` / `Pages/Prompts/Edit.test.js` — form wiring
|
||
|
|
(titles, labels, hrefs) + submit URLs.
|
||
|
|
|
||
|
|
## 10. Conventions
|
||
|
|
|
||
|
|
- Controllers stay < ~40 lines per action: validate → map to DTO → service →
|
||
|
|
respond. No business logic in controllers, requests, or models.
|
||
|
|
- No `dd()`/`dump()` in committed code; no TODOs left in code.
|
||
|
|
- Migrations are forward-only; seeders are idempotent (delete-then-seed demo
|
||
|
|
data).
|
||
|
|
- All new behavior lands in SPECS.md **before** code (this file is the source
|
||
|
|
of truth).
|
||
|
|
- Commits/batches: code written in batches of ≤ 5 files per pass.
|
||
|
|
|
||
|
|
## 11. Decision log
|
||
|
|
|
||
|
|
| # | Date | Decision |
|
||
|
|
|---|------------|----------|
|
||
|
|
| 1 | 2026-10-24 | Build PromptVault (local-first LLM prompt library) as the application. |
|
||
|
|
| 2 | 2026-10-24 | Stack: Laravel (latest) + SQLite + Vue 3 + Inertia + Vite + Tailwind 4; PHPUnit + Vitest. |
|
||
|
|
| 3 | 2026-10-24 | CQRS-lite: command/query DTOs + `PromptService`/`PromptCatalog`; pure `PromptRenderer`. |
|
||
|
|
| 4 | 2026-10-24 | No auth in v1; hard deletes; usage counted on render (`/use`). |
|
||
|
|
| 5 | 2026-10-24 | Strict variable rendering; variable charset `[A-Za-z0-9_-]+`. |
|
||
|
|
| 6 | 2026-10-24 | ≥ 95% coverage of `app/` + `resources/js/` (entry wiring files excluded, disclosed). |
|
||
|
|
| 7 | 2026-10-24 | 10 items/page pagination; sort whitelist; tag names normalized lowercase. |
|
||
|
|
| 8 | 2026-10-24 | Skeleton resolved to Laravel 13 + PHPUnit 12 (skeleton default, kept over Pest) + Vite 8. |
|
||
|
|
| 9 | 2026-10-24 | Resolved deps: `@inertiajs/vue3` 3.x, Vue 3.5, Vitest 5, `inertiajs/inertia-laravel` 3.5. Root view `resources/views/app.blade.php` uses `<inertia:app>` / `<inertia:head>`. |
|
||
|
|
| 10 | 2026-10-25 | Bug fix: `PromptCatalog::list()` used non-existent `orderByNulls()` (500 on `?sort=last_used_at`); replaced with `orderByRaw('last_used_at IS NULL')` + `orderBy()` so NULLs sort last in either direction (§5.1). |
|
||
|
|
| 11 | 2026-10-25 | Bug fix: `PromptService::duplicate()` synced tags via `pluck('tags.id','tags.id')` (always null → corrupt pivot insert); now `pluck('id')` (§7). |
|
||
|
|
| 12 | 2026-10-25 | JS coverage needs `@vitest/coverage-v8` (installed as devDependency); `npm run test:js` / `test:js:coverage` scripts added. |
|
||
|
|
| 13 | 2026-10-25 | `duplicate()` reworked: `$model->replicate(['name','description','body'])` misused Laravel's `$except` semantics (those attributes were *removed* → NOT NULL violation on `body`); replaced with an explicit `Prompt::create()` + tag `sync` (KISS, matches `store`/`update` shape; stats reset via column defaults) (§7). |
|
||
|
|
| 14 | 2026-10-25 | `tags.*` validation changed from `['string','max:40']` to an `is_string` closure + `max:40`: the `string` rule (via `FILTER_VALIDATE_STRING`) rejects empty/whitespace-only entries, contradicting the §5.2 contract that such entries are stripped during normalization. Laravel's global `TrimStrings` + `ConvertEmptyStringsToNull` middleware rewrite empty/whitespace entries to `null` before validation, so the closure accepts `null` (stripped in `tagIds`); other non-strings are rejected (§5.2). |
|
||
|
|
| 15 | 2026-10-25 | Removed unused `PromptCatalog::get()` (single-prompt reads use route model binding + `loadMissing('tags')` — KISS, no dead API, §7); added create-page tests covering `PromptController::create()` (empty form + suggested tags; old-input reflash). Only remaining uncovered PHP lines: two defensive `preg_*`-failure guards in `PromptRenderer` (unreachable with a valid static pattern; deliberate fail-loud contract). |
|
||
|
|
| 16 | 2026-10-26 | Bug fix (caught by the new JS tests): `RenderPreview.vue`'s Use button ran `@click="emit('use', { ...values.value })"` in the template, where the top-level `values` ref is auto-unwrapped — so it spread `undefined` and always emitted `{}`, making the real UI's `/use` flow always 422 ("missing variables"). Moved the emit into a script-scope `emitUse()` method bound via `@click="emitUse"`; the button now emits the actually entered values (§8). |
|
||
|
|
| 17 | 2026-10-26 | JS test harness complete (fulfilling #12): 64 Vitest tests / 10 files under `tests/js/` with the shared Inertia stub + helpers, all green. Coverage of `resources/js/` (excl. disclosed `app.js` entry wiring): **100% lines / 99.57% statements / 96.31% branches / 98.82% functions** — ≥ 95% on every metric. Combined with PHP (60 tests, 99.05% lines), the ≥ 95% application-code coverage requirement (§9) is met for `app/` + `resources/js/`. |
|