Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
177d87e
Stop a focused input from silencing the morph for the whole table
ONyklicek Aug 7, 2026
779ff83
Give the three editors one vocabulary, and TipTap a starting document
ONyklicek Aug 7, 2026
93f0c07
Let a date be typed, and make readOnly() mean something
ONyklicek Aug 7, 2026
a7cabab
Serve the JS bundles as files, mirrored into public/ by the toolkit
ONyklicek Aug 7, 2026
031812b
Resolve a table row's markup once, instead of once per row
ONyklicek Aug 7, 2026
115db04
Stop the asset-publishing test from poisoning the workbench
ONyklicek Aug 8, 2026
37d12e2
Render the row's remaining chrome once per table, not once per row
ONyklicek Aug 8, 2026
e582721
Stop the CDP drivers from reporting a throttled page as a broken one
ONyklicek Aug 8, 2026
1142423
Stop the stacked card from laying itself out on every row
ONyklicek Aug 9, 2026
48dddb1
Update composer minimal request laravel-package-toolkit 2.4.0
ONyklicek Aug 9, 2026
ef28966
Call the toolkit's asset flush outright, now the constraint carries it
ONyklicek Aug 9, 2026
ba41dbd
Compile an action button once per shape, not once per row
ONyklicek Aug 9, 2026
78b349b
Move the row's `<td>` and `<tr>` chrome into Blade
ONyklicek Aug 9, 2026
2137b46
Give the copy affordance one owner, in core
ONyklicek Aug 9, 2026
cb4dce2
Write down what the stacked card would cost to fix, and leave it open
ONyklicek Aug 9, 2026
d2dc1cc
Keep search alive in reorder mode, and stop a drag renumbering the table
ONyklicek Aug 9, 2026
768c299
Require laravel-package-toolkit ^2.4.0, and reach the flush it carries
ONyklicek Aug 9, 2026
9ca23cb
Refuse a searchAs() the search box can never ask for
ONyklicek Aug 9, 2026
b72c2e1
Put the open row context menu back after a morph, instead of losing it
ONyklicek Aug 9, 2026
81ffb1f
Fold the toolbar's header actions into one dropdown on a phone
ONyklicek Aug 9, 2026
e1fbf30
Apply Pint's spacing to the docs-site templates
ONyklicek Aug 9, 2026
43caa38
Log the three 1.17.0 changes that shipped without an entry
ONyklicek Aug 9, 2026
ecf0cda
Update composer.json
ONyklicek Aug 9, 2026
e8e6ee3
Correct the assets docs against toolkit 2.4, which now has a renderer
ONyklicek Aug 9, 2026
fcb2c28
Correct the 2.4 comparison: three gaps, not one
ONyklicek Aug 9, 2026
3317693
Hand the asset tags to the toolkit, and delete the registry that pred…
ONyklicek Aug 10, 2026
969310f
Add placeholder text color
ONyklicek Aug 10, 2026
62129ba
Configure a Select's option modals like every other modal
ONyklicek Aug 10, 2026
fc58a6e
Enumerate a mounted option form as a host form
ONyklicek Aug 10, 2026
5ae7add
Let a wizard hand its navigation to the surface around it
ONyklicek Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 3 additions & 19 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,12 @@ jobs:
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
laravel: ['10.*', '11.*', '12.*', '13.*']
php: ['8.3', '8.4', '8.5']
laravel: ['12.*', '13.*']
exclude:
- php: '8.2'
laravel: '12.*'
- php: '8.2'
laravel: '13.*'

- php: '8.3'
laravel: '13.*'
- php: '8.5'
laravel: '10.*'

name: PHP ${{ matrix.php }} – Laravel ${{ matrix.laravel }}

Expand All @@ -43,19 +38,8 @@ jobs:
run: |
composer require "illuminate/support:${{ matrix.laravel }}" "illuminate/database:${{ matrix.laravel }}" --no-interaction --no-update

- name: Drop wire-boost on Laravel 10 (laravel/mcp requires Laravel 11+)
if: matrix.laravel == '10.*'
run: |
composer remove nyoncode/wire-boost --no-interaction --no-update
composer remove laravel/mcp --dev --no-interaction --no-update

- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress

- name: Run tests
if: matrix.laravel != '10.*'
run: vendor/bin/pest

- name: Run tests (excluding boost on Laravel 10)
if: matrix.laravel == '10.*'
run: vendor/bin/pest --exclude-testsuite="Boost Unit,Boost Feature"
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# AGENTS.md

<!-- laravel-package-toolkit:start -->

## Building this package with laravel-package-toolkit

This package extends `PackageServiceProvider` and describes itself in one method,
`configure(Packager $packager)`. Before writing or changing that method, read the complete
API reference that ships with the installed release:

[vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md](vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md)

It covers every `hasX()` builder, which resources load vs. publish vs. both, how paths
resolve relative to the provider file, the publish-tag format, and the mistakes that fail
silently. Prefer it over recalling the API — it matches the version in `composer.lock`.

<!-- laravel-package-toolkit:end -->
93 changes: 78 additions & 15 deletions AI_CODING_STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ across the `TypeCatalog` is missing a summary.
| Interfaces | `HasLabel`, `HasIcon`, `CanDelete`, `CanSort` |
| Traits | `InteractsWithLabel`, `InteractsWithIcon`, `CanDelete`, `CanSort` |
| Actions | `CreateUser`, `DeleteUser`, `SyncPermissions`, `GenerateColumns` |
| Services | `TranslationService`, `AssetManager`, `NavigationManager` |
| Services | `TranslationService`, `NavigationManager`, `IconManager` |
| Managers | `PluginManager`, `ComponentManager`, `ThemeManager` |

## Directory Structure
Expand Down Expand Up @@ -272,15 +272,78 @@ pattern to eliminate (per-column Htmlable skeletons; see the plans below). Rule
speed are the *same* requirement: self-render, done once. Record-invariant markup MUST
be resolved once, never re-rendered per row.

**Reference implementation:** `Column::renderCellFast()` — resolve `tables.columns.text`
once into a skeleton with a content token, splice `e($state)` per row (measured
byte-identical to `renderCell()` and ~5× cheaper; one view render per column, not V×R).
It falls back to the full `renderCell()` when the skeleton cannot apply — a per-record
url/copy/description-closure (`isCellSkeletonable()`), or a subclass that overrides
`renderCell` with its own view (`supportsCellSkeleton()`). Any new fast path MUST carry
the same two guards: a **byte-identity test** vs the classic render across escaping /
edge-whitespace / unicode / html / empty content, and the **render-count fuse** proving
zero per-row view renders.
**Canonical owner:** `Foundation\View\Skeleton` (core, `Htmlable`) — compile a
rendered template once, `fill()` per row in one `strtr()` pass. Use it rather than a
local token-and-`str_replace`; `strtr` is also the correct primitive, because it does
not re-examine what it just substituted.

**Always `Htmlable`, always Blade — no exceptions.** Markup lives in a `.blade.php`
template, and PHP produces it only through an `Htmlable` owner (`Skeleton`, `HtmlString`,
a component's own `toHtml()` / `getXHtml()`). Raw HTML concatenated from PHP strings is
never acceptable — not for speed, not for a single tag, not when the output is
byte-identical and the suite is green.

**The markup MUST stay in a Blade template. This is not negotiable.** A skeleton is
compiled from `view(...)->render()` — it is a template *rendered once*, never a tag
soup concatenated from PHP strings. Building `'<td class="'.$x.'">…'` in a `@php`
preamble, a helper or a class body is a violation even when the output is byte-identical
and even when it is faster to write: it destroys the `vendor:publish` override point,
puts markup where no Blade tooling, formatter or reviewer looks for it, and splits one
element's markup across two languages. What moves out of the loop is the **render**, not
the template.

```php
// WRONG — markup assembled in PHP, no override point, invisible to Blade tooling.
$cell = Skeleton::compile('<td class="'.$pad.'"><button …>'.$icon.'</button></td>', 'key');

// RIGHT — the partial stays the one source of the markup; only the render moves.
$cell = Skeleton::compile(
view('wire-table::tables.partials.selection-cell', [
'cellPadding' => $this->getCellPadding(),
'keyJs' => Skeleton::slot('keyJs'), // the hole, handed to the template
])->render(),
'keyJs',
);
```

Two consequences worth stating, because they are what makes the Blade version as cheap
as the PHP one:

- **Slots are passed *into* the view as data**, so the template decides where each
per-record value lands and under which encoding — which is what keeps "one slot, one
position, one encoding" a property of the template rather than of the caller.
- **Whitespace between tags is the template's job.** Tags that must touch (`>…<` with
no run between them) are written touching in the Blade; whitespace *between
attributes* is free and stays laid out. A skeleton is not a licence to minify by
moving markup into PHP.

The same rule covers any table/row/cell chrome resolved once per render: put it in a
partial and render it once (`tables.partials.selection-cell`), do not inline it as a
string. Values a template needs that come from a density/variant map (padding,
alignment) get a **getter on the owning object** (`Table::getCellPadding()`), so the
partial and the parent view cannot drift.

**Reference implementation:** `Column::renderCellFast()` — resolves
`tables.columns.text` once into a `Skeleton` and splices per-record values per row
(byte-identical to `renderCell()`, one view render per cell *shape*, not V×R). The
rule that makes it safe is **one slot, one position, one encoding**: the caller hands
each value in already encoded exactly as the template would have encoded it there
(`e()` inside an attribute, raw for markup). A value appearing twice under two
encodings is the boundary where this stops being cheap — see the inline-edit
evaluation in the plan.

A slot substitutes a **value, never a shape**. When a record changes the structure (a
url on one row, none on the next), that is a second skeleton, cached per shape —
O(shapes), not O(rows). The only remaining fallback is a subclass that overrides
`renderCell` with its own view (`supportsCellSkeleton()`).

Any new fast path MUST carry the same two guards: a **byte-identity test** vs the
classic render across escaping / edge-whitespace / unicode / html / empty content
*and* hostile per-record values, and the **render-count fuse** proving zero per-row
view renders. Client-side there is a third: the **payload fuse**
(`TablePayloadFuseTest`) budgets bytes, whitespace text nodes and morph markers per
row — the morph walks every node, and a run of whitespace between tags is one node
however short you make it.

Pick the mechanism by *what varies per row*: **content columns** (structure fixed, only
the value changes) use the **skeleton splice** above; **state-driven columns**
Expand Down Expand Up @@ -434,15 +497,15 @@ else document.addEventListener('alpine:init', register)
it is already too late for the page it fires on.

**Delivery is the other half.** Core interaction controllers must be in the initial
document — a package declares them to `Foundation\Assets\AssetManager` from its own
provider, and the app adds one `@wireStackScripts` to its layout. Downstream packages
document — a package declares them with `hasAssets(entries: [Bundle::make(...)])` in
its own `configure()`, and the app adds one `@wireStackScripts` to its layout. Downstream packages
push their own registration; core never learns they exist. Only the always-present case
is safe on the cached Back/Forward path, where Livewire does **not** wait for newly
injected head scripts before initialising Alpine.

**Lazy-load bodies, never registrators.** Lazy is for heavy, optional assets (rich text,
charts) via `loadedOnRequest()`; the registrar inside such a bundle is still
unconditional. A lazily delivered *registration mechanism* is precisely the bug above.
**Lazy-load bodies, never registrators.** Lazy is for heavy, optional assets (rich
text): leave them out of `entries:` and have the surface deliver them; the registrar
inside such a bundle is still unconditional. A lazily delivered *registration mechanism* is precisely the bug above.

Verify with `verify-spa-navigate` plus the drivers for whatever the bundle touches. See
`architecture/plans/js-asset-registration.md` and ADR
Expand Down
21 changes: 10 additions & 11 deletions AI_COMPONENT_CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Use these before creating local field/column/action helpers:
- `BelongsToComponent`
- `CanBeLive`
- `CanBeReadOnly`
- `CanBeTyped`
- `HasAuthorization`
- `HasColor`
- `HasColumnSpan`
Expand Down Expand Up @@ -66,15 +67,14 @@ Colors/icons:
- `Foundation\Icons\DefaultIconSet`
- `Foundation\Icons\HeroiconsOutlineSet`

Browser assets (canonical owner of every package's JS bundle):
Browser assets (the registry, the URL and the tag belong to the toolkit's
`PackageAssets`; what is core's is the declaration):

- `Foundation\Assets\AssetManager` — container singleton; `register()` per package,
`getScripts()` / `renderScripts()` behind the `@wireStackScripts` directive,
`get()` / `url()` for a surface emitting its own tag
- `Foundation\Assets\Js` — one bundle: `make($id, $filesystemPath)` plus `module()`,
`defer()`, `navigateTrack()`, `navigateOnce()`, `loadedOnRequest()`
- `Foundation\Assets\Contracts\Asset`
- `Exceptions\AssetRegistrationException`
- `Foundation\Assets\Bundle` — `make($shippedFile)` for a declaration every package
shares (classic/IIFE, no `defer`, `data-navigate-once`) and
`servedByRoute($package)` for the `hasAssetFallback()` resolver
- `Foundation\View\FloatingAssets` — the dropdown bundle's URL, by the name a dozen
partials already ask for it

Foundation Blade components:

Expand Down Expand Up @@ -548,9 +548,8 @@ Views:
Assets:

- `packages/sortable/resources/js/sortable.js` → `packages/sortable/dist/wire-sortable.js`
(`npm run build:sortable-assets`; SortableJS compiled in), registered with the core
`AssetManager` as `wire-sortable`/`sortable` and served by the `wire-sortable.asset`
route
(`npm run build:sortable-assets`; SortableJS compiled in), declared as a toolkit
asset entry with the `wire-sortable.asset` route behind it as fallback

## Test Locations

Expand Down
Loading
Loading