tey / kit
Feature kit for tenant-aware Laravel apps, built for the Tey Labs fleet.
Fund package maintenance!
Requires
- php: ~8.4.0 || ~8.5.0
- composer/semver: ^3.4
- glhd/gretel: ^1.10
- illuminate/support: ^13.0
- inertiaui/modal: ^3.1
- lorisleiva/laravel-actions: ^2.12
- spatie/image: ^3.9
- spatie/laravel-data: ^4.23
- spatie/laravel-medialibrary: ^11.13
- spatie/laravel-query-builder: ^7.3
- tey/mod: ^0.11.6
Requires (Dev)
- inertiajs/inertia-laravel: ^3.0
- laravel/fortify: ^1.41
- laravel/framework: ^13.0
- laravel/mcp: ^1.0.2
- laravel/passport: ^13.10
- laravel/pennant: ^1.0
- laravel/pint: ^1.32
- phpstan/phpstan: ^2.1
- spatie/laravel-multitenancy: ^4.2
- spatie/laravel-settings: ^3.9
- spatie/laravel-typescript-transformer: ^3.3
- spatie/typescript-transformer: ^3.3
- symfony/event-dispatcher-contracts: ^3.5
- symfony/polyfill-php83: ^1.32
- tey/concierge: ^0.8.4 || ^0.9 || ^0.11.1
- tey/standards: ^0.11.54
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.15.51
- v0.15.50
- v0.15.49
- v0.15.48
- v0.15.47
- v0.15.46
- v0.15.45
- v0.15.44
- v0.15.43
- v0.15.42
- v0.15.41
- v0.15.40
- v0.15.39
- v0.15.38
- v0.15.37
- v0.15.36
- v0.15.35
- v0.15.34
- v0.15.33
- v0.15.32
- v0.15.31
- v0.15.30
- v0.15.29
- v0.15.28
- v0.15.27
- v0.15.26
- v0.15.25
- v0.15.24
- v0.15.23
- v0.15.22
- v0.15.21
- v0.15.20
- v0.15.19
- v0.15.18
- v0.15.17
- v0.15.16
- v0.15.15
- v0.15.14
- v0.15.13
- v0.15.12
- v0.15.11
- v0.15.10
- v0.15.9
- v0.15.8
- v0.15.7
- v0.15.6
- v0.15.5
- v0.15.4
- v0.15.3
- v0.15.2
- v0.15.1
- v0.15.0
- v0.14.1
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.0
- v0.9.2
- v0.9.1
- v0.9.0
- v0.8.1
- v0.8.0
- v0.7.66
- v0.7.65
- v0.7.64
- v0.7.63
- v0.7.62
- v0.7.61
- v0.7.60
- v0.7.59
- v0.7.58
- v0.7.57
- v0.7.56
- v0.7.55
- v0.7.54
- v0.7.53
- v0.7.52
- v0.7.51
- v0.7.50
- v0.7.49
- v0.7.48
- v0.7.47
- v0.7.46
- v0.7.45
- v0.7.44
- v0.7.43
- v0.7.42
- v0.7.41
- v0.7.40
- v0.7.39
- v0.7.38
- v0.7.37
- v0.7.36
- v0.7.35
- v0.7.34
- v0.7.33
- v0.7.32
- v0.7.31
- v0.7.30
- v0.7.29
- v0.7.28
- v0.7.27
- v0.7.26
- v0.7.25
- v0.7.24
- v0.7.23
- v0.7.22
- v0.7.21
- v0.7.20
- v0.7.19
- v0.7.18
- v0.7.17
- v0.7.16
- v0.7.15
- v0.7.14
- v0.7.13
- v0.7.12
- v0.7.11
- v0.7.10
- v0.7.9
- v0.7.8
- v0.7.7
- v0.7.6
- v0.7.5
- v0.7.4
- v0.7.3
- v0.7.2
- v0.7.1
- v0.7.0
- v0.6.13
- v0.6.12
- v0.6.11
- v0.6.10
- v0.6.9
- v0.6.8
- v0.6.7
- v0.6.6
- v0.6.5
- v0.6.4
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- 0.5.x-dev
- v0.5.117
- v0.5.116
- v0.5.115
- v0.5.114
- v0.5.113
- v0.5.112
- v0.5.111
- v0.5.110
- v0.5.109
- v0.5.108
- v0.5.107
- v0.5.106
- v0.5.105
- v0.5.104
- v0.5.103
- v0.5.102
- v0.5.101
- v0.5.100
- v0.5.99
- v0.5.98
- v0.5.97
- v0.5.96
- v0.5.95
- v0.5.94
- v0.5.93
- v0.5.92
- v0.5.91
- v0.5.90
- v0.5.89
- v0.5.88
- v0.5.87
- v0.5.86
- v0.5.85
- v0.5.84
- v0.5.83
- v0.5.82
- v0.5.81
- v0.5.80
- v0.5.79
- v0.5.78
- v0.5.77
- v0.5.76
- v0.5.75
- v0.5.74
- v0.5.73
- v0.5.72
- v0.5.71
- v0.5.70
- v0.5.69
- v0.5.68
- v0.5.67
- v0.5.66
- v0.5.65
- v0.5.64
- v0.5.63
- v0.5.62
- v0.5.61
- v0.5.60
- v0.5.59
- v0.5.58
- v0.5.57
- v0.5.56
- v0.5.55
- v0.5.54
- v0.5.53
- v0.5.52
- v0.5.51
- v0.5.50
- v0.5.49
- v0.5.48
- v0.5.47
- v0.5.46
- v0.5.45
- v0.5.44
- v0.5.43
- v0.5.42
- v0.5.41
- v0.5.40
- v0.5.39
- v0.5.38
- v0.5.37
- v0.5.36
- v0.5.35
- v0.5.34
- v0.5.33
- v0.5.32
- v0.5.31
- v0.5.30
- v0.5.29
- v0.5.28
- v0.5.27
- v0.5.26
- v0.5.25
- v0.5.24
- v0.5.23
- v0.5.22
- v0.5.21
- v0.5.20
- v0.5.19
- v0.5.18
- v0.5.17
- v0.5.16
- v0.5.15
- v0.5.14
- v0.5.13
- v0.5.12
- v0.5.11
- v0.5.10
- v0.5.9
- v0.5.8
- v0.5.7
- v0.5.6
- v0.5.5
- v0.5.4
- v0.5.3
- v0.5.2
- v0.5.1
- v0.5.0
- v0.4.33
- v0.4.32
- v0.4.31
- v0.4.30
- v0.4.29
- v0.4.28
- v0.4.27
- v0.4.26
- v0.4.25
- v0.4.24
- v0.4.23
- v0.4.22
- v0.4.21
- v0.4.20
- v0.4.19
- v0.4.18
- v0.4.17
- v0.4.16
- v0.4.15
- v0.4.14
- v0.4.13
- v0.4.12
- v0.4.11
- v0.4.10
- v0.4.9
- v0.4.8
- v0.4.7
- v0.4.6
- v0.4.5
- v0.4.4
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-fix/brand-env-empty
- dev-release/v0.15.51
- dev-release/v0.15.50
- dev-fix/youcard-minute-tick
- dev-release/v0.15.49
- dev-release/v0.15.48
- dev-feat/mail-button-palette
- dev-release/v0.15.47
- dev-feat/youcard-ab-style
- dev-release/v0.15.46
- dev-feat/not-active-403
- dev-release/v0.15.45
- dev-feat/brand-own-accent
- dev-release/v0.15.44
- dev-fix/welcome-card-compat
- dev-release/v0.15.43
- dev-fix/bar-floor-under-lg
- dev-release/v0.15.42
- dev-release/v0.15.41
- dev-feat/dashboard-setup-card
- dev-fix/nav-search-shrinks-first
- dev-release/v0.15.37
- dev-brand-icon
- dev-fix/lockup-mark-size
- dev-feat/theme-presets
- dev-fix/mail-subcopy
- dev-feat/0.8-fonts-self-default
- dev-docs/upgrade-guide-ms-notes
- dev-fix/user-menu-and-settings-width-0.5.x
- dev-perf/lineage-memo
- dev-fix/field-a11y
- dev-fix/stub-phpstan
- dev-feat/rich-text-view
- dev-feat/dashboard-registry-gaps
- dev-fix/table-query-server
- dev-feat/retire-tenant-boundary
- dev-fix/history-after-62
- dev-feat/shell-live
- dev-chore/changelog-headings
This package is auto-updated.
Last update: 2026-11-09 18:03:59 UTC
README
Built for the Tey Labs fleet. Public but unsupported: no support, no stability promise, use at your own risk.
tey/kit
Feature kit for tenant-aware Laravel apps, built for the Tey Labs fleet. An app adopts features from a catalog with kit:add, one Mod lineage per feature, updates each with kit:update --lineage=<feature>, and lists what the catalog offers with kit:list. kit:status is planned and does not exist yet. The package carries an opinionated versioned application contract and a public Mod scaffold pack. A/B and C/D are local synthetic release fixtures, not published versions. Runtime tenancy/membership/settings stay in maintained packages and host bindings.
Quick start
composer require tey/kit php artisan kit:install
kit:install writes the app-owned host binding kit:add needs: app/Kit/KitHost.php, the operation check in app/Kit/operations.php, config/kit-lifecycle.php (verification policy and app id), config/kit-executor.json (the Mod runtime) and app/Providers/KitHostServiceProvider.php, and adds that provider to bootstrap/providers.php. Those files are yours: no feature owns them, and kit:add and kit:update never touch them, except kit:update refresh-executor, which you run after upgrading tey/mod to record the new runtime in config/kit-executor.json. Until you do, every plan stops once with executor_stale, naming the recorded and running versions and that command. It refuses to overwrite any existing file, and a second run changes nothing.
Review what it prints, above all the operation check, which fails closed until the host schema exists (php artisan migrate). workspaces also needs your user model to be a workspace member. app/Models/User.php is yours, so kit never writes it: add the lines yourself.
use Tey\Concierge\Contracts\Member; use Tey\Kit\Features\Workspaces\Concerns\HasWorkspaces; class User extends Authenticatable implements Member { use HasWorkspaces; }
kit.models.user (else the users auth provider's model) and concierge.models.user must name that model. Until it implements Member, the preview lists the host step as unmet, with the lines to add, and the plan is not executable. Then preview, apply and migrate:
php artisan kit:add workspaces php artisan kit:add workspaces --apply --expected-plan=<the plan the preview printed> php artisan migrate
Without kit:install (or your own LifecycleHost binding), kit:add refuses.
Recipes and guides:
- Public site: core, shell and session-ui with the PublicShell, registration off, no workspaces, workspace pages or invitations.
- Upgrading from kit 0.2.x: claim core, then move kit and mod together.
- The host's repository: which
.mod-maintenancefiles to commit, and setting up a fresh checkout or CI.
The scaffold id is tey/kit and its command is kit:foundation. This package was renamed before first publication; there are no aliases. See the changelog.
Fonts
An app picks one font set by name. Put the component in your layout head:
<head> <x-kit::fonts /> </head>
It links the set's fonts from fonts.bunny.net and sets four variables on :root: --font-sans (text), --font-serif (display), --font-mono and --font-display-weight, each with system fallbacks. Pages and the Tailwind theme use only those variables. Tailwind v4's font-sans, font-serif and font-mono read them already, so don't set font families anywhere else. For display text, use font-serif with font-weight: var(--font-display-weight).
Under a Content Security Policy, every tag the component writes carries a nonce: <x-kit::fonts nonce="{{ $nonce }}" />, or, without the attribute, the one your app set with Vite::useCspNonce(). With neither, no nonce attribute is written.
| Set | Text (--font-sans) |
Display (--font-serif) |
Mono (--font-mono) |
|---|---|---|---|
instrument (default) |
Instrument Sans 400/500/600 | Instrument Serif 400 and italic, regular | IBM Plex Mono 400/500 |
instrument-inter |
Inter 400/500/600 | Instrument Sans 600, semibold | IBM Plex Mono 400/500 |
inter-serif |
Inter 400/500/600 | Instrument Serif 400 and italic | IBM Plex Mono 400/500 |
hanken |
Hanken Grotesk 400/500/600 | DM Serif Display 400, regular | DM Mono 400/500 |
To switch sets, set KIT_FONTS in .env, or fonts in a published config/kit.php:
KIT_FONTS=hanken
An unknown set name is refused when the component renders, and the error lists the four sets.
Self-hosted fonts
By default the faces come from fonts.bunny.net. To serve them from your own origin instead, with no third-party request at runtime, set KIT_FONTS_SOURCE=self. Pages then work offline, and scripts can read the page's own stylesheet rules. Then publish the files:
KIT_FONTS_SOURCE=self php artisan vendor:publish --tag=kit-fonts --force
- What's published: the kit ships every set's faces as woff2 files, in the same subsets bunny serves, in
resources/fonts. Publishing copies them topublic/vendor/kit/fonts. The component then writes one<style>: an@font-faceper face of the chosen set, withasset()URLs, then the same:rootvariables. - Licences: every family is under the SIL Open Font License. The licences ship in
resources/fonts/licenses. - Upgrading: run the publish command again (with
--force) after a kit upgrade that changes the files. Alternatively, commitpublic/vendor/kit/fonts. - Files missing: if they haven't been published, the component throws in local and testing. Elsewhere it falls back to the system fonts, never the CDN, and
kit:preflightwarns. - Maintainers: after changing
Fonts::SETS, runphp bin/fonts-fetch.phpto refresh the files,faces.jsonand the licences.FontsCheckfails until every set's faces are shipped.
Features
The app is a set of features. Each feature has a manifest at <catalog>/<id>/feature.json with its id, owned paths, generated files (with their stubs), requirements (other features and packages), conflicts and host-owned paths. An optional host_steps names changes only the host can make. kit:add and kit:update plan verify each one and print it; they never write it:
user_model: the user model must implementimplementsand use the traituses, checked by reflection. It blocks: while it is unmet the plan is not executable, and--resumerefuses it too.workspace_model:concierge.models.workspaceandmultitenancy.tenant_modelmust name one Eloquent model that implementsimplementsand spatie'sIsTenantand uses the traituses, checked by reflection. It blocks asuser_modeldoes. See Adopting into an existing workspaces table.env([{key, why, or_file?}]): the key must be documented in the host's.env.example. The kit never reads.env. Met means documented, not configured, so set the key in each environment. Withor_file, the step is also met when that file exists, for a secret kept as a file (such asstorage/oauth-private.key). Only its existence is checked; the file is never read, and a linked one doesn't count.provider([{class, why}]): the service provider must be registered.schedule([{command, why}]): a scheduled event must run the command. Any form counts, wherever the schedule lives (routes/console.php, bootstrap/app.php'swithSchedule, a Console Kernel):Schedule::command('model:prune --model=X'),Schedule::command('model:prune', ['--model' => [X::class]]), a string option value, or several models.config([{key, not, why}]): the config value atkeymust not benot. It is checked where the plan runs, so it is met for that environment only; each deploy environment may differ.theme([{file, why}]): the host's stylesheet (core declaresresources/css/app.css) imports the kit'skit.css, which carries the colour tokens' defaults, or defines every semantic token the kit's components use itself. The token can be a plain variable (--primary:) or a theme mapping (--color-primary:). Unmet, it names the missing tokens and the import. It reads that file, every CSS entry yourvite.config.{ts,js,mjs}names (sokit.cssimported from a second Vite entry counts), and the local files each one@imports, transitively (inside the app, never through a link). It never runs the Vite config.mention([{file, contains, why}]): the host file must mentioncontains(a cheap text check, never blocking). A step onvite.config.tsreads whichever Vite config the app has (.ts,.jsor.mjs). A step on a script underresources/js(app.ts) also reads every script entry the Vite config names (next.ts), and any of them mentioning it meets it.
The others never block. An unmet one is listed under steps, added to host_steps, and raises attention. So does a requires.npm entry that the host's package.json doesn't declare (missing), declares with a range that allows older versions (outdated), or declares in a form the kit can't read (unverifiable: workspace:, a git URL, a tag). The kit installs nothing. An optional optional list ([{id, title, do}]) names integrations a host may add; they're printed as manual and never checked. The text preview opens with an ATTENTION block that lists every warning. The JSON output adds steps, npm (constraint, declared range, status, and the installed version from node_modules with its own installed_status), optional, attention, warnings, and per feature migrations (new, changed, existing); every existing key is unchanged. A kit:add plan identity binds each step's status and each npm requirement's constraint, declared range and status, so a change to them after review needs a fresh preview. The installed version isn't bound, so an npm ci between preview and apply keeps the plan. kit:update plan keeps Mod's plan identity: the declarations are bound through the target artifact, and their outcomes are reported only. <catalog>/blueprint.json holds what every selection shares: the artifact name, version, groups, migration order and verification policy.
Colour tokens. The kit's components are coloured with shadcn-vue's semantic tokens (bg-primary, text-muted-foreground, border-input, ring-ring and the rest). kit.css defines them all at the lowest precedence, so a host with no theme still renders the kit in colour, and any host theme wins:
- an
@theme default inlineblock maps each--color-*to its plain variable; a host's own@themereplaces it; - the starter kit's neutral values sit on
:where(:root)and:where(.dark), which any:rootor.darkof the host overrides, whatever the import order. - status text on a surface has its own tokens, beyond shadcn-vue's:
--destructive-text(an error, a destructive menu item),--success-text(FormActions' "Saved"),--warning-textand--info-text(an Alert's icon), astext-destructive-text,text-success-textand so on.--destructive-foregroundstays text on a destructive background. The kit's values meet WCAG AA (4.5:1) on background, card, popover, accent and muted, light and dark; a host theme that sets its own colours should set these too, since the theme step lists them. No kit stub colours text with a raw palette colour (text-green-600).
Import it after Tailwind: @import './kit.css';.
Host UI primitives (requires.host_ui). The kit's components import a few UI primitives from the host's @/components/ui (shadcn-vue's): core declares button, checkbox, dropdown-menu, input, label, sheet and spinner/Spinner.vue. A name is met by resources/js/components/ui/<name>/index.ts, and a <name>/<File> entry by that file. A missing one is a broken build, so it blocks: kit:add, kit:update plan and kit:adopt plan list one host_ui step in unmet with the exact command, for example npx shadcn-vue@latest add button checkbox dropdown-menu input label sheet spinner (only the missing ones), and report each primitive under host_ui. An update or adoption checks the primitives the files it keeps import. The kit's library check fails when a stub imports a primitive its feature does not declare. The kit has its own cn (resources/js/components/Kit/utils.ts, clsx and tailwind-merge), so nothing depends on the host's @/lib/utils.
@inertiajs/core. @inertiajs/vue3 pins @inertiajs/core exactly, so core requires @inertiajs/vue3 and not @inertiajs/core. No kit stub imports @inertiajs/core (the library check enforces it): the few Inertia types it needs are its own, in resources/js/components/Kit/types.ts. A host package.json that also declares @inertiajs/core at another version than vue3 pins (an exact match is fine), or a node_modules holding two copies of it, raises attention: two copies' types disagree, and PageProps augmentation stops applying. Remove the host's @inertiajs/core line (or set it to vue3's exact version).
The package catalog, Catalog::package(), is the starter catalog: core, workspaces, access, settings, shell, session-ui, workspace-ui and mcp, plus the opt-in notes (docs/catalog.md). The v0.1 features foundation (the policy file) and defaults (the defaults file, which requires foundation) are a regression fixture in tests/fixtures/v01, loaded by tests/fixtures/v01.php; they are not in the package catalog. The whole app is every feature. Select features with Blueprint::features(['foundation'], $catalog), Blueprint::all($catalog), or config('kit.scaffold_features') for the v0.1 scaffold fixture, which builds from the bound host's catalog when it implements CatalogHost.
A selection is checked before anything is built. It is refused when a feature is unknown or listed twice, a required feature is missing, two features conflict, a file sits outside its feature's owned paths or inside a host-owned path, two features own the same path, or two features disagree on a package constraint.
Each feature is content-addressed: its manifest bytes and its stub bytes are pinned in Blueprint. A selection's source reference covers the composed manifest, every resource byte and every feature's pinned identity. The order you list features in does not change it. The selection is recorded in the manifest and in the artifact metadata (blueprint_features).
Mod refuses changes to an artifact's name, groups or bindings, and refuses removals. kit:add installs each feature as its own lineage (tey/kit:<feature>). The release fixtures and kit:update --features keep the older single-lineage path, one growing artifact:
- A first adoption has no baseline. The original is an empty artifact with the same name, groups, bindings and checks as the target, and no metadata, as Mod builds its own. Existing paths are refused as unowned, even when the bytes match.
- Adding a feature is an upgrade from the old selection to a larger one on the same artifact.
- Removing a feature is refused with a clear message, before any other check.
kit:add claims existing files only from an explicit, reviewed list. Its preview lists each existing file whose bytes already equal a feature's file as claimable and leaves it unowned, so the plan is not executable. Review each one, then list it with --claim=<path> (repeat the option, or separate paths with commas) in both the preview and --apply. The claim list is part of the plan identity. Only listed paths are claimed, without a write. A listed file with different bytes is a conflict, and an unlisted one is refused as unowned. A claim that names no file of a feature being added is refused.
When an apply stops, kit:add <features> --resume --intent=<id> (with the same --claim list) finishes the interrupted feature, then adds every remaining feature of the request in requires order. It prints which features are done and which remain, and exits nonzero unless all of them completed. While a lineage has an unfinished update, kit:add and kit:update refuse with pending:<lineage>, then print that lineage's intent and the exact resume command. kit:update status lists each unfinished journal as Pending (resume it) or Stale (an update that completed, whose target identity is already installed), and exits nonzero while any remains; kit:update status --clear-stale removes the stale ones only and prints each one. A completed kit:update apply --lineage clears its lineage's stale journals, and kit:update resume on a stale journal clears it.
An app that installed features before lineages existed adopts them in place: kit:adopt <feature> plan, then kit:adopt <feature> apply --intent=<id> --exclusive-checkout --expected-plan=<plan>, one feature at a time in requires order. Plain kit:update prints these commands on such an app, and each adoption plan prints its exact apply command (apply in its JSON). A new migration creating a table the host already has makes the plan not executable, as in kit:add. Adoption overwrites nothing:
- A file identical to a known release (
resources/features/history.json, or the current catalog) is claimed. - A missing file is created.
- An edited file stays the app's, recorded against the release it came from:
--from=<release>, else the closest one.
Each lineage's plan is reviewed on its own. A plan whose feature requires one with an update still pending lists it in depends_on and gives the order in order: plan and apply that one first. There is no single up-front review of every pending lineage yet.
From then on kit:update --lineage=<feature> leaves a kept file alone until a release changes it. It is then a conflict, as an edited owned file is, and it is claimed once the app's bytes match. A plan that would replace a file under database/migrations/ lists it in changed_migrations. The apply is refused without --accept-changed-migrations, because a migration that already ran never runs again: make the schema change in a new migration.
Keeping a file for good, or handing it back. A file the app changed and means to keep (its own Layout.vue) shouldn't block every later update.
php artisan kit:update keep <path> [--lineage=<feature>]plans it as host-kept, and the printed apply command records it. The lineage stops owning it: an owned file is released to the app untouched, and a file kept since adoption stops being kept. No later update writes it, claims it or conflicts on it, and the lineage's other files still update (a re-pin's new partial is written).- Tweak in the app, graduate when proven. Put
// kit: tweaked <reason>(or<!-- kit: tweaked <reason> -->in a Vue template) in the first five lines of an edited kit file, and the kit treats the edit as a deliberate tweak:kit:statuslists it astweaked, a graduation candidate, and--jsongives each lineage'stweaks(path => reason).kit:status --checkfails on an unmarked edit (drift) and passes a marked tweak.kit:updatekeeps the tweak as if you had runkeep: no conflict and no write. It flags the tweak undertweakedand reports upstream kit changes underheld. Useclaimto hand the file back once the tweak has graduated.
- Each
kit:update plan --lineage=<feature>lists, underheld, anATTENTION:line for each kept file the kit changed upstream since the host last saw it, with the stub to diff against, so you can port the change by hand. That includes a change made before the keep: what the host last saw is the release its edit was made against (for a file kept since adoption) or the bytes installed (for an owned file). Each change is reported once; the next apply records it. php artisan kit:update claim <path>is the reverse: the file's current bytes become the lineage's original, so the next update replaces it with the kit's bytes while it's unchanged since the claim, and plans a conflict once it's edited after.- Neither writes a file; each changes only the record, after a reviewed plan (
--expected-plan,--exclusive-checkout,--intent).
Per-feature artifacts need an additive Mod API and are not part of 0.1. The manifest shape is ready for it.
Adopting into an existing workspaces table
A host app that already has its own workspaces table, its own workspace model and spatie/laravel-permission's tables (with teams) adds the workspaces and access features without a second workspaces table or second permission tables.
-
Bind your model. Publish
config/concierge.phpandconfig/multitenancy.php, and setmodels.workspaceandtenant_modelto the same class. The model extends spatie'sTenant(or implementsIsTenant), implementsTey\Concierge\Contracts\Workspaceand usesTey\Kit\Features\Workspaces\Concerns\IsKitWorkspace, which carries the kit's slug hooks,memberOfscope,conciergeName(),conciergeAcceptsMembers()(fromadmitted) andrequired(). The kit creates workspaces withname,admittedandprovisioning_invitation_id, so those must be fillable. The plan refuses a binding where the two keys differ or the model misses the contract,IsTenantor the trait (workspace_model).use Spatie\Multitenancy\Models\Tenant; use Tey\Concierge\Contracts\Workspace as ConciergeWorkspace; use Tey\Kit\Features\Workspaces\Concerns\IsKitWorkspace; class Workspace extends Tenant implements ConciergeWorkspace { use IsKitWorkspace; protected $guarded = []; }
-
Plan.
kit:add workspaces accesslists each host table it adopts (adoptionsin the JSON, anadopt the host's ... table in placeline in the text) instead of refusing it as a collision. Adoptions never block. Any other new migration creating a table the host has still blocks. So do permission tables that exist only in part, or that lack the configured teams column (permission.column_names.team_foreign_key,workspace_idby default) whilepermission.teamsis on. Fresh databases. When the plan adopts, it records the adoption in the workspaces lineage's installed record and publishes the migrations of every feature it adds that reaches workspaces (workspaces, access, settings, mcp, notes) as<stamp>_<order>_<name>.phpinstead of0001_01_02_<order>_<name>.php. The stamp is one second after your latest migration underdatabase/migrations(outsidedatabase/migrations/kit), and no earlier than today (UTC). So on an empty database (CI, a new developer, tests)migrateruns your ownworkspacesand permission migrations first, then the kit's, and ends with the same schema as adopting over an existing database. Commit your own migrations before you runkit:add.kit.workspaces.adoptandkit.access.adopt(null by default, which reads that record;KIT_WORKSPACES_ADOPTandKIT_ACCESS_ADOPT, or your ownconfig/kit.php, override it; set them to true before planning when the database you plan against doesn't have your tables yet) tell the workspaces and permission migrations that the tables are yours. They then refuse, rather than create, a table that doesn't exist yet. A host that adopts nothing keeps the catalog's names.kit:updatenever renames an installed migration, and a migration a later release adds takes the install's stamp (or the catalog's name on a non-adopting install). -
Migrate. The workspaces migration adds only the columns the kit needs and the table lacks:
admitted(default true; every existing row is set to true),settings_version(default 1; every existing row is set to 1),provisioning_invitation_id(nullable, unique) andslug(filled from the name for each row without one). It never drops or alters a host column. The permission migration creates nothing when all five spatie tables exist. -
Seed operators before deploy. With workspaces, tenant admin (
concierge.manage-tenants) is a row inoperatorson a verified, active account. Concierge's default, theconcierge.tenant_invitations.platform_ownersemails, no longer applies, andoperatorsstarts empty, so without a seed the platform owner loses tenant admin on deploy.php artisan kit:operators:seed --from-platform-owners --dry-runlists what it would seed; without--dry-runit adds an operator for each platform-owner email whose user (matched on the trimmed, lower-casedemailcolumn) is verified and active. It is idempotent, and reports already-operators, skipped users (unverified or inactive) and emails with no user. While platform owners are configured andoperatorsis empty,kit:add/kit:adoptplans show an attention step (never blocking) with the emails that would match,kit:statuswarns, and boot logs a warning inlocalandtestingonly (production boot runs no query for it). That output shows addresses masked (j***@example.com) with counts; only an interactive--dry-runshows unmatched emails in full, so you can fix them. The seed and the step readconfig('concierge.tenant_invitations.platform_owners'), never the environment directly, so a cached config decides. The step is part of the reviewed plan: seeding betweenkit:addpreview and--applychanges the plan identity, so re-run the preview and apply with the new one.composer test:operators-seedcovers it on SQLite.
Kit code reaches the workspace only through Tey\Kit\Features\Workspaces\Workspaces (model(), query(), current(), required()), and its type hints name the Concierge contract, so every kit path returns your model. Models\Workspace stays the default for kit hosts, and nothing changes for them.
Rolling back: each adoption is recorded when it happens, in a kit-owned kit_adoptions table (the table name, the columns the kit added, when), which the first adoption creates and a fresh kit install never has. The kit's down() migrations read that record: an adopted workspaces table keeps its rows and every host column, and loses only the columns the record says the kit added (then the record goes); adopted permission tables are kept whole. A table the kit created is dropped as before. Renaming or dropping host columns later changes nothing about this.
Rolling everything back on an adopting host (migrate:reset, or migrate:refresh). The kit's 000204 and 000207 put a foreign key from audit_events.workspace_id onto your own workspaces table. A reset runs every migration's down() in one batch, newest file first, so your create_workspaces migration's down() runs before the kit's, and dropping the table fails on that key. The kit's own down()s come too late to help. Drop the key first in your migration's down():
public function down(): void { // The kit's audit_events key onto this table (000204/000207): dropped first, or the table can't be dropped. SQLite can't drop a key by name, and // doesn't need to: it drops the table and the key's ON DELETE SET NULL clears the column. if (Schema::hasTable('audit_events') && Schema::getConnection()->getDriverName() !== 'sqlite') { foreach (Schema::getForeignKeys('audit_events') as $key) { if ($key['columns'] === ['workspace_id'] && $key['foreign_table'] === 'workspaces') { Schema::table('audit_events', fn (Blueprint $table) => $table->dropForeign($key['name'])); } } } Schema::dropIfExists('workspaces'); }
A plan that adopts your workspaces table lists this as a host step.
Some things a rollback leaves in place, on purpose:
- The audit key migrations are forward-only.
0001_01_02_000204to000207have no-opdown()s. After a rollback,audit_events.workspace_idstays nullable and keeps itsON DELETE SET NULLkey. Making the column required again would fail on rows whose workspace was deleted, and restoring a key withoutSET NULLwould block deleting those workspaces again. Only000208'sworkspace_namecolumn is dropped. - The access seed survives. Rolling back
accessdropskit_permission_defaults, the record of which default grantskit:permissions:syncapplied. The permissions it created (such asworkspace.members.manage) and theirrole_has_permissionsrows for owner and admin stay, because your app may have assigned them since. To remove them by hand, delete the permissions you no longer want from spatie'spermissionstable (theirrole_has_permissionsandmodel_has_permissionsrows go with them by cascade), then runphp artisan permission:cache-reset.
migrate --pretend on an adopting host, before the kit's migrations run, lists the adoption's statements (the columns added to your workspaces table and the kit_adoptions record) instead of refusing. Pretend runs no query, so the migrations can't see that your tables exist, and with kit.workspaces.adopt or kit.access.adopt on they take the adoption path. With the flags off it shows the statements that create the tables.
Not covered: composer test:adopt-fresh runs kit:add and kit:update through Lineages (not the artisan commands) on a synthetic catalog copy carrying the real workspaces and access migrations, then compares the fresh-database schema with the production adoption and runs migrate --pretend on an adopting host, on SQLite and in CI's PostgreSQL job (its own kit_fresh_*, kit_prod_* and kit_plain_* schemas). composer test:workspace-adopt runs on SQLite memory and, in CI's separate PostgreSQL job, against a PostgreSQL service container (DB_CONNECTION=pgsql with the DB_* variables; it drops and recreates the public and kit_second schemas, so point it only at a throwaway database). There is no MySQL run and no end-to-end kit:add against a real host app.
Adopting existing access and settings tables
A host may already have the access feature's entitlements or complimentary_grants table, or spatie/laravel-settings' own table (settings.repositories.database.table, settings by default). The kit never creates over one. kit:add, kit:update plan and kit:adopt plan list a compatible table under adoptions (adopt the host's <table> table in place); the migration then creates, adds and changes nothing on it, records it in kit_adoptions, and creates only the tables the host lacks (for settings, settings_intents). An incompatible table blocks the plan, and the migration refuses it before creating anything, with each problem named. A table is compatible when:
- every column the kit reads or writes exists, in a compatible type family (integer, string, time, boolean, json; on SQLite a json column is text):
entitlementsneedsid,workspace_id,kind,revoked_at;complimentary_grantsneedsid,workspace_id,granted_by,intent_key,fingerprint,revoked_by,revocation_reason,reason,expires_at,revoked_at; the settings table needsid,group,name,locked,payload,created_at,updated_at; idauto-increments, and each column the kit writes null into (revoked_at,revoked_by,revocation_reason, the timestamps) is nullable;- the unique index the kit relies on exists, on the same columns in any order:
(granted_by, intent_key)oncomplimentary_grants,(group, name)on the settings table; - on a table the kit inserts into (
complimentary_grants, the settings table), every other host column is nullable, has a default or auto-increments, so the kit's inserts succeed. Other host columns and indexes are kept as they are.
A table whose schema the kit cannot read is a refusal, never a create. A database the plan cannot reach at all is not checked there, as for collisions; the migration still checks before it creates. kit:adopt --json with no feature lists each such table the host has under tables, as adopted (recorded), adoptable or conflict (with its problems). Rolling back keeps an adopted table and its rows, and drops only the record. On a fresh database, the kit's 0001_01_02_* migrations sort before your own, so the access migration would create complimentary_grants before yours does unless the adoption is stamped (above, for hosts that adopt their workspaces or permission tables); run your own migration first there. composer test:table-adopt covers it on SQLite and in CI's PostgreSQL job (its own kit_table_* schemas, dropped and recreated, so point it only at a throwaway database).
Row-level security (PostgreSQL)
An opt-in second tenant lock under the app's workspace scoping (KIT_RLS=true, kit.tenancy.rls). Workspace tables carry a policy that matches workspace_id against a transaction-local setting (set_config(..., true), never a session SET). Each workspace request and each queued job runs in one transaction that carries its workspace id, and a job with no workspace fails closed. The app connects as a NOBYPASSRLS runtime role (php artisan kit:rls:role) that owns no table; migrations run on a separate admin connection. Rls::protect('table') protects a table in a migration, and kit:preflight fails when the runtime role bypasses RLS or owns a table, or when a workspace table is unprotected. PostgreSQL only: on other drivers it is a no-op and the database enforces nothing. See docs/rls.md for setup, Laravel Cloud, pooler caveats and what isn't covered.
Feature library
The starter's features run from the library under src/Features/<Feature> (Tey\Kit\Features\...): core, workspaces, access, settings, shell, session-ui, workspace-ui, mcp and notes. Tey\Kit\Features\FeaturesServiceProvider (auto-discovered) registers the providers of the adopted features, config('kit.features') or else the installed record's blueprint_features, loads their migrations from database/migrations/kit/<feature>, and fills the Registry they share: routes, MCP tools, navigation, token scopes, abilities, capabilities, provisioning steps, the connection guard and error codes. A selection with an unknown feature, a duplicate or a missing requirement is refused.
Routes and config ship in the library: no feature generates a config file. The kit's defaults merge in with nothing published; php artisan vendor:publish --tag=kit-config writes config/kit.php as the app's own file, which kit updates never touch. kit.routes.<feature> takes enabled, prefix, middleware and name (a route-name prefix). Other keys: kit.models.user (default: the users auth provider's model), kit.capabilities (capability => on), kit.invitation_redirect (a route name taking workspace; default: the first navigation link) and kit.mcp.connection_ability (default settings.read). A delegated (MCP) principal is refused unless mcp is adopted. Controller redirects still use /workspaces/... paths, so a route prefix does not move them yet (kit #12).
Capabilities and Pennant
Declare a capability from your provider's register() (app(Registry::class)->capabilities([...])), with the rest of its contributions. The kit defines each declared capability as a Pennant feature once every provider has booted, so one declared in boot() is defined too, but register() is the documented place.
A capability's default comes from kit.capabilities, resolved through Pennant, and Pennant stores what it resolved for each workspace (the features table on the database driver). Changing kit.capabilities later does not change a workspace that already resolved it: run php artisan pennant:purge <capability> (or pennant:purge for all) after the change, for example in the deploy that ships it. Operator overrides (kit:capability, kit_capability_overrides) are not in Pennant and survive a purge. The kit does not purge for you: a value your app set itself with Feature::for($workspace)->activate() (a plan, say) lives in the same rows and a purge would drop it.
Listing the catalog
kit:list prints each feature of the catalog: its id, the features it requires and the paths it owns. --json prints the same as JSON. It is read-only and uses the bound host's catalog when the host implements CatalogHost, else the package catalog.
External catalogs
Catalog::package() is this package's own catalog. A consuming package can ship its own. A Catalog is a directory with a blueprint.json and a <feature>/feature.json per feature, plus the pinned identity of each:
$catalog = Catalog::at($root, [ 'core' => 'sha256:…', 'settings' => 'sha256:…', ], basePin: 'sha256:…'); Blueprint::features(['core', 'settings'], $catalog); Blueprint::all($catalog);
The pins list the features in canonical order. Every check that applies to the package catalog applies to an external one: a wrong pin, a changed manifest, a changed stub, a stub added to or removed from a tree, or a changed blueprint.json is refused with the same identity error. A catalog pins its own features only; features of other catalogs are not available in it. Release fixtures A–D stay package-only.
A feature can list its files, or point at a tree of stubs, or both:
"files_from": {"stubs": "stubs", "target": "app/Notes"}
The tree is expanded the same way every time: files sorted by relative path, each target is target plus the relative path with a trailing .stub dropped, and ownership is generated. An optional scaffold key sets the scaffold (default kit). Explicit files entries come first. The result is the same map you would get by listing every file. Dotfiles and dot directories are skipped, unless you list one in files. A symlink, anything that is not a regular file, a path with .., an absolute path, a target outside the feature's owns, or two stubs for one target is refused. The feature's identity hashes the manifest and the bytes of every expanded stub.
To let kit:update use a catalog, implement CatalogHost on the bound LifecycleHost. Its catalog() is used to build the --features and --original-features blueprints. A host without it uses the package catalog. The command does not change. --target and --original still name release fixtures. An app or package can implement CatalogHost for its own catalog and keep its own commands. A host's own command can also build blueprints with Blueprint::features($ids, $catalog) and call Lifecycle::plan, apply and resume directly.
A–D stay as release fixtures. They are separate regression evidence, not feature sets: their stub bytes carry their own versions (0.1.0 to 0.4.0). Their manifests use the groups key, and their pinned identities changed with it (see the changelog).
The tey/kit scaffold uses public released Mod APIs. Preview with kit:foundation --dry-run --json; then generate explicitly with --no-interaction. A/B generate one policy file. C/D add a separately owned defaults file for the two-file upgrade proof. It does not establish a bootable/authenticated application, persisted ownership, upgrade/recovery, browser or product-MCP acceptance.
Generated paths and host-owned branding/domain/configuration/data are distinct in the blueprint. The source reference content-addresses the manifest plus all resource bytes; baseline resources remain available for comparison. Generic lifecycle machinery lives in Mod. No private Mod APIs, forced overwrites, semantic merging, package publication or automatic production updates.
The initial recipe fixes the member name and ungrouped placement. Alternate command names cannot widen ownership. Only the Laravel layout with all declared paths and classes is supported; incompatible layouts refuse before generation.
The original A/B resource bytes and source references are pinned and retained. New C/D fixtures represent logical consumer baseline/target versions 0.3.0/0.4.0; both policy and defaults resources change. Each member has a fixed class/path, and every member validates the entire placement set before generation starts. Defaults occupy their own explicit folder so released Mod reverse-mapping remains unambiguous. Ordinary generation preserves existing customised output; it is not the persistent upgrade protocol.
Run php tests/ScaffoldCheck.php <autoload.php> C (also A/B/D) against a public Laravel 13 / Mod 0.9 install. The optional misplace-defaults argument checks that a mismatched second member refuses before writing the first. Each run creates a distinct synthetic filesystem fixture, not a real application consumer or an installed ownership record.
18 October 2026: Added immutable two-file C/D proof resources and whole-recipe placement validation; initial A/B identities retained.
The lifecycle adapter composes only public Mod Artifact, Checkout, FilePlan and Maintenance APIs. It runs on released tey/mod ^0.9 as installed from Packagist. It needs no local Mod source, overlay or source verifier. It refuses to run on any other Mod version. Mod publishes the Maintenance API; this package adds the blueprint, its identity and the host contract.
Bind LifecycleHost explicitly to application-owned code for kit:update. It supplies the app ID, frontend bindings, actual named input files, a Lifecycle, and a real operation/tenant acceptance closure. There is no default success callback. Required input IDs are verification-policy, verification-code, dependency-lock, host-config, host-binding, host-operations and executor-provenance. Executor provenance is required for every executor: write the exact typed runtime() result as JSON before preview. It is checked against the Mod version actually installed and fingerprinted as an execution input, not treated as proof on its own. These must name the actual policy, registered implementation, lock, configuration and binding files; include additional relevant package/verifier implementation inputs. The operation closure must be declared in the file named by host-operations; reflection rejects an unbound implementation before delivery. Package and resource checks run before effects and again before recording, including after the host operation callback. Host code cannot replace package dependency/resource checks.
Preview with kit:update plan --target=C, or kit:update plan --features=foundation,defaults to select features from a catalog host. Review its plan identity before kit:update apply --target=C --intent=<stable-intent> --expected-plan=<identity> --exclusive-checkout. Upgrade with --original=C --target=D, or add features with --original-features=foundation --features=foundation,defaults; retry an interrupted exact intent with kit:update resume and the same original/target/intent. On an upgrade, the original you name must rebuild exactly the installed artifact, features and bindings included; otherwise the command refuses before Mod does. Every refusal or noncompletion exits nonzero, including conflicts, failed verification and unresolved orphan candidates. Completed cold retries use the authoritative receipt without rerunning host callbacks. No force, semantic merge, migration execution or orphan deletion exists.
The artifact's inert metadata contains the pinned blueprint source, complete manifest and truthful empty applied-migration IDs. Requirements, migration declarations, exceptions and host ownership are provenance, not execution evidence. The generic Mod installed record remains the sole version authority, committed last. Executor provenance is separate from immutable artifact provenance. It is exposed in results and bound through the checked executor-provenance input in the authoritative installed commit. A changed executor and input can run a new upgrade intent against the unchanged historical artifact; they cannot reuse an old intent. The input is host configuration, never a competing installed-version record. All paths require an exclusively owned checkout. Advisory locking cannot protect against arbitrary editors; preserve/refuse conflicts and unresolved recovery files.
Run php tests/CatalogCheck.php <autoload.php> for external catalogs, stub trees and the catalog host, against the fixture catalog in tests/fixtures/catalog. Run php tests/FeatureCheck.php <autoload.php> for manifest validation and feature growth rules (no lifecycle API needed). Run php tests/ScaffoldCheck.php <autoload.php> features:foundation,defaults for a feature selection from the v0.1 fixture catalog. Run php tests/LifecycleCheck.php <autoload.php> for the lifecycle against the Mod in vendor: first adoption, plan and apply, additive upgrade, refused removal, an unowned path, resume, and a cold retry. php tests/ResourceCheck.php <autoload.php> tampers only fresh disposable package copies. Run php tests/StarterCatalogCheck.php <autoload.php> for the starter catalog: pins, the requires graph, stub compilation, ownership, kit:list and migration loading. Run php tests/LibraryCheck.php <autoload.php> for the feature library: its dependency graph against the starter's, autoloading, and the registry each selection builds. CI runs all of these on PHP 8.4 and 8.5. These callbacks and files are synthetic adapter evidence, not consumer operation/tenant/browser/database acceptance. ScaffoldCheck.php compares snapshots to actual public released scaffold output for every A/B/C/D release.
The tests install the blueprint's peer packages as dev dependencies, so the compatibility check runs on real released versions, including tey/concierge ^0.7.3 with Mod 0.9.
19 October 2026: Keep immutable blueprint identity independent of the current executor; bind verified executor provenance as an execution input and preserve same-intent staleness across new-executor upgrades.
19 October 2026: Added per-feature manifests, a no-baseline adopt path and an add-only feature upgrade on one growing artifact. The manifest key modules is now groups; Mod's own Artifact::$modules reads wait for Mod 0.8.
The shell
The shell feature needs only core. It generates the signed-in shell, pages/Workspace/Layout.vue with its Partials/, and the guest shell, Partials/GuestShell.vue. A host with its own tenancy adopts core and shell and builds a Tey\Kit\Features\Shell\Data\ShellData itself. The shell feature shares the shell Inertia prop on every page, built by the bound Tey\Kit\Features\Shell\ShellSource: on a core and shell host, PlainShell (the product and the footer, plus the signed-in user's name, with no workspace), so the PublicShell's footer and CTA get their data without session-ui; with session-ui, its PageShell. Nothing in the kit flushes Inertia's shared props or shares shell again, so the prop has one producer, beside the host's own HandleInertiaRequests shares. A host that builds its own binds its own ShellSource. The host's one layout callback picks the shell: GuestShell for auth pages and whenever the shared user is null, Layout.vue otherwise. Pages declare no layout.
- Layout slots.
mark,extra,actions(in the header, and in the drawer belowlg), the default slot andfooter, plusbar. Thebarslot is for a compact control that must stay in the bar at every width, such as a search opener. It renders once, never in the drawer, and it shrinks before the switcher and the menu button do. Pass it through your layout wrapper rather than teleporting into the shell's markup: the shell's selectors are not an API. - Module tabs. A module's sibling pages show as PageTabs at the top of the page, under the breadcrumb bar and above the heading, the current one marked. A feature registers them with
Registry::modulePage($module, $ability, $label, $suffix, order:, active:), where$moduleis the suffix of the module's primary navigation link. A module owns that prefix and its pages sit under it (bookings/calendarfor modulebookings); in local and testing a page outside it logs a warning that suggests the nested suffix, while production stays silent and nothing throws.active(here and onnavigation()) is one path pattern under the workspace or a list of them, for a module that spans several prefixes; an entryroute:<name pattern>(route:trips.entries.*) matches the current route's name instead, and a link is current when any entry matches. The server puts them in the page's typedlayout.navigation(PageLayout::$navigation, oneNavGroupnamed for the module): only the pages the viewer may open (a page behind an ability they lack is not in the props at all), in order, the most specific match current. A page outside a settings section gets them while one of the module's pages is current; a ViewModel that sets its own navigation replaces them. No component reads the Registry or the shell for them.- Who draws them. A top-level
<Page>in the shell draws itslayout.navigationas PageTabs. It draws none when it is nested in another<Page>, inside SettingsFrame (whose sidebar is that navigation), or when the page renders its own<PageTabs>anywhere inside it: that PageTabs claims the row, so there is only one. Placing them in<Page>and not the Layout keeps them in the page's column, keeps the one-trail and nested-Page rules in one component, and lets a record frame claim them. - Module tabs and record tabs. A record's sections are PageTabs too, from the record ViewModel's own
layout.navigation, drawn inside the record frame under its header (as a case page with tabs does). The record's navigation replaces the module's, and the module tabs show only while one of the module's own pages is current, so a record page never gets two rows. The trail bar leads back to the module. - The SubNav strip.
<Layout sub-nav="strip">keeps the old strip of links under the header, fromShellData::$subNavigation, and<Page>then draws no tabs. It scrolls sideways on a phone. It is not the "Back to parent" strip (layout.navigationHeader), and the two can sit together.
- Who draws them. A top-level
- GuestShell. Props:
title,description,width(narroworwide),backUrlandbackLabel(the labelled way back to your site; the shell guesses no URL),appearance(the light/dark switch, on by default) andproduct(else the sharedshell.product). Slots: the card body,footerandmark. It needs no ShellData, user, workspace or auth route, so it also serves a cacheable public page or an error page that keeps its real status. - Types. ShellData and ShellKind moved from
SessionUi\DatatoShell\Data. The old PHP names are class aliases, and shell generatesresources/js/types/kit-shell.d.tsaliasing the old TypeScript names, so a layout typed against either still checks. - Installed hosts. An app that installed session-ui before the split moves the shell's files over with ordinary per-lineage updates, each planned, reviewed and applied with its own
--intent:kit:update --lineage=session-uireleases them (session-ui goes to an interim version that does not yet require shell),kit:update --lineage=shellinstalls shell at exactly those bytes, claiming them without a write, and then--lineage=shellagain updates it to its target and--lineage=session-uiagain finishes. Each result'snextnames the following run. If you have edited a moved file, the first step writes nothing and prints the diff from the installed file to yours: put your changes aside, restore the file, run the steps, then reapply them. Needs tey/mod with removals (main, 0.11). A feature'smoved({path, to}) andremoved({path, policy}, withdelete-if-unchangedorkeep-as-host) list the paths an earlier version owned; an update that drops a path neither lists is refused. - Replacing the user menu or switcher. The Layout imports
UserMenuandWorkspaceSwitcheronly through@kit/shell, an alias for the generatedresources/js/pages/Workspace/Partials/index.ts. Add it beside@kit/ui('@kit/shell': path.resolve(__dirname, 'resources/js/pages/Workspace/Partials/index.ts'), and the same intsconfig.jsonpaths). To replace one, point the alias at your own module that re-exports the rest. - Tokens. The shell's colours (
--shell-bar,--shell-subbar,--shell-band,--shell-mark-*) come from the host'sresources/css/app.css, as in the starter. A host without them sets them there.--shell-groundis the Layout's page ground: unset, it is the theme's background. - Layout options. Each is off by default unless noted.
toaster(on by default):falseleaves the toasts to your own root shell's Toaster.documentTitle(on by default):falseleaves the<title>to your pages' own<Head>.fill: an editor page at the viewport's height, with no page scroll and an unpadded content area that fills what the header leaves.wordmarkLabelnames the wordmark link, for example with a tenant's name. Without it the product's name does.guestHeaderdraws the header for a guest too, with no user menu and no drawer.breadcrumbsplaces the trail.bar(the default) is a full-width bar under the header: the whole trail, the current page included as its last crumb (markedaria-current), so a module's top page shows its trail too (the workspace's home icon, then the module: Home › Bookings).<Page>then draws no trail line, and a page with no trail gets no bar.page(opt-in) is<Page>'s small line above the heading, ancestors only. Either way the trail is drawn once: a<Page>nested in another (a tabbed frame round each tab) draws none of its own.subNav:tabs(the default) lets<Page>draw the module's pages as tabs;stripkeeps the SubNav strip under the header.- Breakout pages. A page that leaves its parent's shared layout (a full-screen bench, an editor) shows "Back to " in place of the trail, in either mode. Declare it with the route default
->defaults('kit.breakout', true)or the ViewModel'sbreaksOut(). The strip is derived from the trail's parent crumb, so it can't drift from the trail. A page inside its parent's layout (a tab, a settings section) is not a breakout and keeps its trail. Account pages (/me/*) and a page's ownnavigationHeader()show a strip the same way. A breakout with no parent crumb, or inside a settings section, throws in local and testing; in production it draws no strip and keeps the trail. - With no pages to list (no
navordrawer-navslot and an emptynavigation), there is no drawer. The account menu andactionsstay in the bar at every width.
Theming
Your app's own resources/css/app.css themes the kit. The rule has four parts:
- You may remap the surface tokens, in
:rootand in.dark. - Set each surface with its foreground. Whoever sets a surface sets its pair too, so text on it stays readable.
- The kit owns its
--kit-*component tokens and the contrast pairs it ships. Don't override them, except on the surfaces named below. kit:statuschecks this as advice, never as a failure.
| Surface | Its foreground |
|---|---|
--background |
--foreground |
--card |
--card-foreground |
--popover |
--popover-foreground |
--muted |
--muted-foreground |
--sidebar |
--sidebar-foreground |
--shell-ground (the shell's page ground; the theme's background when unset) |
--foreground |
The kit's headings (the Page heading, and GuestShell's and the public Markdown page's titles) use --font-display, the serif stack by default. A sans app sets --font-display: var(--font-sans) in its @theme or :root.
You may set --kit-* only on [data-shell-bar], [data-shell-subbar] and [data-navigation-header].
:root { --background: oklch(0.99 0.004 85); --foreground: oklch(0.21 0.02 60); --card: oklch(1 0 0); --card-foreground: oklch(0.21 0.02 60); --shell-ground: oklch(0.985 0.006 85); /* a lighter page ground than the background */ } .dark { --background: oklch(0.17 0.01 60); --foreground: oklch(0.96 0.005 85); --card: oklch(0.21 0.01 60); --card-foreground: oklch(0.96 0.005 85); }
The kit:status advisory. kit:status (and every kit:add or kit:update plan) reads your Vite CSS entries and the files they @import, and lists "Theming (advisory)" lines for:
- a surface set without its foreground;
- a pair below 4.5:1 contrast, from hex,
rgb()oroklch()values (avar(), a colour with alpha or a named colour is skipped, not guessed); - a
--kit-*override outside the named surfaces.
These lines are attention only: they never block a plan or fail a command.
Tinted page grounds
The page ground is white by default. A theme whose ground is tinted (a warm paper, a pale green) declares it with one token, in its own resources/css/app.css:
:root { --kit-ground: tinted; --background: #f3f5e7; /* the ground (or --shell-ground for the shell alone) */ --card: #ffffff; /* the surface the kit draws on it */ }
It is a CSS token rather than a config value because it belongs with the colours that make the ground tinted: one file decides both, a brand preset or a dark theme can carry its own, and a linter can read it from resources/css. The kit reads it from the root's computed style when a component is set up (the kit renders in the browser only; there is no server rendering to disagree with). Declare it in the stylesheet the app loads before it mounts.
What draws a surface. On a tinted ground these draw their own card, bg-card text-card-foreground border rounded-xl (a dashboard widget's look):
- DataTable: one card around its tabs, toolbar, filters, rows and pages; the rows inside are flat.
- EmptyState.
- Workbench: one card around its panes.
- PageSection: its content, below the section header.
surfaceiscardby default there;surface="none"leaves it on the ground. - The form layouts (FormCard with its FormActions, and the form Section) are cards on any ground already.
Never a card in a card. Surface, Card, FormCard, Section, a dashboard widget, Dialog and Modal provide a card context (provideCard(), from @kit/ui; useInCard() reads it), and so does each component that drew its ground card. A component inside one draws no card of its own, and a DataTable inside one keeps its rows flat. Your own card-like wrapper should call provideCard(), or be a kit Card.
What stays on the ground. Page and PageGrid (and its cells) are transparent, and the page chrome stays on the ground: the page header and its actions, breadcrumbs, SubNav, PageTabs and section headings.
On a white ground nothing changes: no card is added and no class differs.
Minimums. composer test:token-contrast checks every shipped theme and a sample tinted theme (tests/fixtures/tinted-ground-theme.css, ground #f3f5e7), light and dark. The ground is --shell-ground, else --background; the surface is --card.
| Pair | Minimum |
|---|---|
the surface (--card) against the ground |
1.1:1, and the card has a --border edge |
--border against --card |
1.5:1 |
a control's edge (--input) against its surface (--card) |
3:1 |
--foreground on the ground, --card-foreground on the surface, and --muted-foreground on both |
4.5:1 |
A brand can set the edge with the others it prints: kit.brand.input (KIT_BRAND_INPUT) prints --input next to --border, and input_dark (KIT_BRAND_INPUT_DARK) its dark twin, so an app on a tinted ground need not print it itself. Unset prints nothing. Brand::contrastProblems() reads a configured edge against the brand's card, else its page, at 3:1, light and dark.
The first three apply to a tinted ground; the shipped themes are white grounds, where only the text pairs do. White against #f3f5e7 is 1.10:1, so a ground that pale needs a white card. The browser check ?tinted renders a DataTable, a FormCard, an EmptyState and PageSections on the sample ground at 1280px (light and dark), checks each card's edge and background and that none is nested, runs axe, and checks the white ground draws no card.
Auth-only session-ui and workspace-ui
session-ui is the sign-in, and needs only core and shell: Auth/Login, Auth/ForgotPassword, Auth/ResetPassword, Auth/ConfirmPassword (Fortify's password.confirm), Auth/TwoFactor (the challenge), Auth/VerifyEmail and Auth/Register, with Fortify's routes behind them, plus the person's welcome steps (Welcome/Timezone, Welcome/Photo), the avatar routes, switch account, the passkey offer, the per-person Workbench layouts and its two tables (security events, user state). It installs no workspace, access, Concierge, Pennant or multitenancy, so a single-owner site signs in with core, shell and session-ui alone. Registration is closed by default (accounts come from invitations, or from the host): an app that turns on Fortify's registration feature, and opens Concierge's registration mode (concierge.registration.mode) where Concierge is installed, gets Auth/Register at /register, posting to Fortify, which creates the account with the app's CreatesNewUsers action, and a "Create an account" link on sign in (Concierge's open mode only). An app's own sign-up (an invited account, say) renders the same page from its own route with a RegisterViewModel of its own: its action, heading, lead and submitLabel, an address shown as text in place of the email input (email, emailHint), values posted that are not inputs (submit) and its own 429 copy (throttleMessage); an error on a key with no input is one alert at the top of the card. workspace-ui requires session-ui (and workspaces, access and shell) and holds the workspace pages: the kit's home (kit.home) and the dashboard, search, the settings and account landing pages (Workspace/Sections), Settings › People, Settings/Layout.vue, the account pages under /me (Workspace/Account), the placeholders, Workspace/Error, Workspace/Index and Provision/NoWorkspace, with the dashboard widgets, the people search provider, the error pages and the workspace opener; and, since 0.6.0, the invitation link (Auth/Invitation/Show, its acceptance, the branded invitation email), the owner's workspace-timezone welcome step (Welcome/WorkspaceTimezone) and the first-run demo's sign-in. notes requires workspace-ui (its dashboard widget and trail). workspace-ui also has the owner's Transfer ownership and Delete workspace actions and the person's Delete account. Each deletion dispatches lifecycle events, after the commit, so a host can purge its own data in a queued listener (docs/lifecycle-events.md).
- Auth-only host.
php artisan kit:add session-ui(it brings core and shell; add workspaces and access yourself if you want them, and not workspace-ui). The kit serves no page atkit.home: that is the app's own, and after sign-in Fortify sends the person there (the kit'sfortify.homedefault iskit.home; a publishedconfig/fortify.phpdecides for itself). Serve your signed-in landing atkit.home(/unless you setKIT_HOME);kit:statusreports attention while no route answers GET there. The last welcome step lands there too, and the welcome pages' trails start at themselves. Your own error page renders errors (the kit's is workspace-ui's). There is no invitation page: invitations are workspace-ui's. Add workspace-ui later withkit:add workspace-ui. - Same routes, same names. workspace-ui's routes are the ones session-ui had, with the same names, loaded right after session-ui's and governed by
kit.routes.session-ui(prefix, middleware, name, enabled), so nothing a host configured moves. Its classes keep theirTey\Kit\Features\SessionUi\...names. A selection with workspace-ui generates exactly the files session-ui generated before the split, at the same bytes; only their owner changes. - Installed hosts. An app that installed session-ui before the split keeps its workspace pages on after
composer update: workspace-ui is carried, for its lineages and for akit.featureslist, while session-ui's installed lineage still owns those pages or has released them, and once workspace-ui's lineage is installed. Move the files with the per-lineage updates of the shell split, each planned, reviewed and applied with its own--intent:kit:update --lineage=session-uireleases the 22 pages (nothing is written or deleted),--lineage=workspace-uiclaims them at exactly those bytes with no write,--lineage=workspace-uiagain updates it to its target and--lineage=session-uiagain finishes. Then--lineage=notes, if you have it (it now requires workspace-ui). Each result'snextnames the following run; the CHANGELOG has the exact commands. If you have edited a moved page, the first step writes nothing and prints the diff from the installed file to yours: put your changes aside, restore the file, run the steps, then reapply them. Akit.featureslist inconfig/kit.phpshould nameworkspace-uiaftersession-ui. - The 0.6.0 move. The invitation page and the workspace-timezone step moved from session-ui to workspace-ui, with their routes and hooks. An app with both installed re-pins once:
kit:update --lineage=session-uireleases the two files and--lineage=workspace-uiclaims them at their bytes, with nothing written or deleted. Only the first split's pages carry workspace-ui: for an app with session-ui alone,kit:update --lineage=session-uireleases the two files in one run and keeps them on disk, unchanged, as the app's (listed underkept); nothing is deleted and no workspace-ui step follows. workspace-ui is needed only for invitations (kit:add workspace-ui).
Scrolling lists
ScrollList (the @kit/ui component) renders a Tey\Kit\Features\Core\Lists\ScrollListViewModel: a cursor-paged list that loads more as the viewer scrolls, with an always-present "Load more" button. A list declares its key(), title(), an already ordered and tenant-scoped query(), a row() mapping and its first-use empty() state; the page passes $list->items() as the prop named by the key. The continuation is encrypted and binds the viewer, workspace, list, selection and mode, so a replayed or tampered one is a 400 and never a first page. This is the cursor-only, fixed-selection, count-free subset: sorts, segments, totals, polling, page and simple paging modes and ScrollWidgetViewModel from the spec are not included. Cursor order is deterministic but not a snapshot: edited or moved records can move between windows.
Use your own components
Kit pages and the shell import every UI primitive (the form set, buttons, cards, menus) only through one alias, @kit/ui, as in import { Field, Input, Button } from '@kit/ui'. By default the alias resolves to the kit's own set: core generates it into your app at resources/js/components/Kit/ (index.ts, contract.ts and Form/*), built on the shadcn-vue primitives in @/components/ui (button, input, label, checkbox, dropdown-menu, sheet, from Laravel's MIT Vue starter kit) and Reka UI. Plain Tailwind otherwise.
Pages follow the ViewModel standard: a page's props are one typed ViewModel (Tey\Kit\Features\Core\ViewModels\PageViewModel; every public method with no required parameters is a prop, with a declared return type and a docblock shape for arrays), and the generated TypeScript is read from those methods, so defineProps<EditClientViewModel>() restates nothing. AdHocViewModel is for a small page (three props at most, no action or permission prop, one render site; the guard fails one that outgrows that). Data classes are values and forms, never the page. There are two producers and two keys: shell (the product and viewer, the workspace switcher, the primary navigation and a small shell-only can map, shared by the middleware) and the page's own layout() (layout.navigation for the section navigation, title, documentTitle and breadcrumbs, all decided on the server). Pages declare no layout: the nearest folder Layout.vue (pages/Workspace/Settings, pages/Workspace/Account) renders the section navigation and stays mounted while you move between its sections. Permission checks are memoized per request. Literal types: Spatie's transformer writes a docblock literal union (@return 'queued'|'running', @param 1|2 $level) as unknown. Register the kit's transformers in your typescript-transformer config instead of Spatie's: Tey\Kit\TypeScript\ViewModelTransformer and Tey\Kit\TypeScript\AttributedDataTransformer (in place of AttributedClassTransformer), and those literals come out as TypeScript literal unions. Until then, #[LiteralTypeScriptType("'queued' | 'running'")] on the property works with any transformer.
Extending General settings. To add fields to the workspace's General settings page:
- Subclass
Tey\Kit\Features\Settings\ViewModels\EditSettingsViewModeland add prop methods. - Register the subclass in a service provider with
EditSettingsViewModel::extendWith(AppSettingsViewModel::class). - Type the page
defineProps<AppSettingsViewModel>(), marking the page edit// kit: tweaked <reason>.
The kit builds your class with its own constructor arguments, so read anything more from the container inside your prop methods. The generated type is the kit's props plus yours. Registering a class that isn't a subclass fails.
Page purpose. Every page states who opens it and their main job, in one line on its ViewModel's class docblock: @purpose <who>: <main job>, for example @purpose Staff: watch unsigned agreements; send one occasionally. Write it first, before choosing components; the page follows from that job. It's a docblock tag, so there are no props, no TypeScript and no runtime cost. The kit's own page ViewModels carry one, and tey/standards' ui.page-purpose rule warns on a page without it.
Nested ViewModels. A child component gets its own ViewModel: extend Tey\Kit\Features\Core\ViewModels\ComponentViewModel, which has no page concerns.
- The parent returns the child from a prop method:
public function summary(): SummaryViewModel. - The page passes it on:
<Summary v-bind="vm.summary" />. - The component declares
defineProps<SummaryViewModel>(). - A list is
/** @return list<RowViewModel> */, passed on withv-forandv-bind. A nullable child (?SummaryViewModel) works the same way.
The generated page type references the child types, and the parent's payload nests each child's props.
The brand lives in the app's own config/brand.php, a committed file with the same keys and nesting as kit.brand (publish the commented stub with php artisan vendor:publish --tag=kit-brand, or write it from today's env with php artisan kit:brand:migrate). Each key resolves at boot in this order: the value in config/brand.php when the key is present (an explicit null included), else its KIT_BRAND_* env value, else the kit's default. The kit writes the result back to config('kit.brand'), which every reader keeps using, and config:cache caches both. Env brand values are deprecated and removed in 0.16: each key that still comes from env logs one deprecation per process, naming the key and kit:brand:migrate. kit:status lists every brand key with its source (config, env (deprecated) or default).
Colours are a #rgb or #rrggbb (a #rgb is widened). null means "use the theme" (or the kit's default): for the accent, the theme's --primary, as an empty accent did before. An empty string is invalid, because it is what an unquoted # in .env leaves (KIT_BRAND_ACCENT=#c2410c is read as a comment): in local and testing the boot throws, naming the key; anywhere else it logs a warning and the key takes the kit's default, never an empty value. kit:brand:migrate writes config/brand.php with only the keys that come from env, at their loaded values; a key in .env that loaded empty is written as null with a comment, never a guessed value, so set the real colour there. It refuses to overwrite an existing config/brand.php without --force, never edits .env, and prints the .env lines to delete. See docs/upgrading-brand-config.md.
The brand is config('kit.brand'): the product's name and logomark, the accent and its companions, the sender addresses and the footer lines. The web and mail footers omit any part that is empty (no dangling separator, no "Built by" with nothing after it). KIT_BRAND_PRIVATE=true is for a private app with no public legal entity: the legal name, postal address, Terms and Privacy are left out of both footers, and Brand::placeholders() (what kit:brand reports as still to fill in) no longer asks for them. Beyond the accent, the brand can set surfaces (background, card, border, muted, input, each with a _dark twin), link colours (--brand-link), the button, header and marker roles apart from style, a rule under the header, a dark logomark and a lockup; every one is optional and unset prints nothing (see the stub, resources/stubs/brand.php). KIT_BRAND_PRESETS and the local-only ?brand= switch compare accent variants side by side.
The lockup (KIT_BRAND_LOCKUP, the mark with the wordmark or the product's name) shows where KIT_BRAND_LOCKUP_SCOPE says: guest (the default: guest and focus pages; mail and the social image use their own images) or everywhere (the signed-in bar too). In the bar it is capped at 16rem (45vw on a narrow screen) and its name truncates, so a long workspace name beside it always has room; the bar keeps [data-shell-product-name] for assistive tech.
In the signed-in bar the logomark is painted to the bar's mark colour with the theme's brightness and invert filters (--shell-mark-brightness, --shell-mark-invert). A mark drawn for the bar, a coloured one say, goes in KIT_BRAND_LOGOMARK_BAR (kit.brand.logomark_bar, a path in public/): the bar then shows that image unfiltered, in light and dark mode, and guest pages keep logomark and logomark_dark. Unset, the bar is as before. kit:brand does not read it.
A lockup reads best with a tightly cropped logomark, which may be wide. kit:brand draws the favicons, the touch icon and the mail mark from KIT_BRAND_ICON (kit.brand.icon, an SVG path in public/ or an absolute path) when it is set, and from logomark when it is not; kit:brand --check compares against the same source. The social image keeps logomark, beside the product's name. Whichever source is used, a mark that isn't square is padded to a centred square before it is rasterised, so no asset is stretched or squashed; a square mark renders exactly as before.
php artisan kit:brand renders the favicon, touch icon and mail mark from the icon SVG (KIT_BRAND_ICON, else the logomark) and the social image from the logomark, with Imagick (SVG support) or rsvg-convert. ImageMagick's own SVG renderer, without the librsvg delegate, draws no strokes, so a stroked logomark or icon (a stroke attribute, or stroke: in a style) would come out as a dot: kit:brand renders it with rsvg-convert when that is installed, and otherwise stops and says to outline the strokes (Inkscape: Path › Stroke to Path; Illustrator: Object › Path › Outline Stroke; Figma: Outline stroke) or install librsvg. A mark of filled shapes renders anywhere.
The accent for these assets is kit.brand.accent. Set it to null in config/brand.php ("use the theme") and kit:brand draws them in the accent the theme resolves to: the light-mode --primary (or --color-primary) in :root of resources/css/app.css, the Vite CSS entries and their local imports, as hex, rgb() or oklch(). The app's own value wins over a kit.css copy's default. When no stylesheet sets one it can read (only a var(), say), kit:brand stops, says to set the accent, and writes nothing, so no asset is ever drawn in an empty colour. A set accent, and the default zinc, work as before.
Some apps draw an output by hand, a favicon.ico tuned pixel by pixel say. List those in kit.brand.own (in config/kit.php; names in public/, such as ['favicon.ico', 'og-image.png'], a leading / or public/ is ignored): kit:brand never overwrites them, prints skipped public/<file> (the app's own, kit.brand.own) for each, and --check skips them instead of calling them out of date. The default is an empty list, so kit:brand writes every file, as before.
The account pages under /me (Profile, Password, Security) use Fortify's own features and actions, wired by the kit: the name and email (a changed address stays unverified until its link is followed, and the page says so), the password (other sessions are signed out afterwards), and two-factor authentication (the password is asked for again, a code from the authenticator turns it on, recovery codes are shown once, and turning it off or regenerating the codes confirms first). The host's user model needs Fortify's TwoFactorAuthenticatable trait and MustVerifyEmail, and the users table Fortify's two-factor columns (the starter has both). Signed-in devices can be listed and ended only with SESSION_DRIVER=database; the page says so otherwise.
Sessions for MCP: set SESSION_DRIVER=database (or redis) explicitly in every environment, not the platform default. Passport keeps the consent request in the session, which outgrows a cookie session's 4 KB, so consent would fail. On SESSION_DRIVER=cookie the consent routes refuse with an error page and log why, and the mcp plan flags it as attention. The tey/standards preflight install.sessions also checks it at deploy. The security log is the kit's kit_security_events table, written from the sign-in, failed sign-in, password and two-factor events; it belongs to the user, is never shown in a workspace and is kept.
Forms follow one shape: Inertia's <Form> (uncontrolled, so every control submits a native named value) with the kit's Field name, ending in FormCard or FormActions: the bar with the form's status, Cancel and the submit button, sticky, with a leave-page warning while the form is dirty; a destructive action goes through ConfirmDialog, and Delete lives in its own danger-zone section, never in the bar. The peer dependency is @inertiajs/vue3 ^3 (useFormContext needs 2.3.9 or later). A form saved by the app's own handler (a fetch, useHttp, a socket) with no visit uses useSubmitForm as the :form of FormCard, FormActions or Workbench.
The kit's Reka components need reka-ui 2.11.0 or later (InputSelect does not typecheck on earlier releases). Core lists it under requires.npm; the host's package.json must provide it, because kit:add does not install npm packages. Its preview checks the declared range and flags a missing or older one.
Add the alias in vite.config.ts and tsconfig.json:
// vite.config.ts resolve: { alias: { '@kit/ui': path.resolve(__dirname, 'resources/js/components/Kit/index.ts') } }
// tsconfig.json, compilerOptions "paths": { "@kit/ui": ["./resources/js/components/Kit/index.ts"] }
In dev, add kitImports() from resources/js/components/Kit/kitImports.js to the Vite plugins, after vue(). It rewrites each @kit/ui import into imports of the files it names, so a page loads only the kit components it uses. Production builds are unchanged. See docs/kit-imports.md.
To use your own components, point @kit/ui at a module of yours that exports the same names, with the props in resources/js/components/Kit/contract.ts. The kit's pages import these from it (mcp's Authorize page imports InputSelect, for instance), so your module must export each of them: ActivityStream, Alert, AppearanceToggle, Avatar, Badge, Breadcrumbs, browserTimezone, BulkDrop, Button, Checklist, DataTable, DateTime, Dialog, Dropdown, DropdownConfirm, DropdownItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, EmptyState, Field, FileInput, FormActions, FormCard, FormStatus, Input, InputCheckbox, InputSelect, Modal, opensSearch, Page, PageEnd, PageGrid, PageSection, passkeyName, PasskeyOffer, passkeySignInSupported, passkeysSupported, PasswordInput, Placeholder, provideCard, RadioCardGroup, registerPasskey, ScrollList, SearchBox, Section, SectionIcon, Select, SettingsFrame, Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger, signInWithPasskey, Stack, Surface, Textarea, TimezoneSelect, Toaster. Kit pages then render your components, with no forking of the pages. Re-export what you do not replace: export * from '@/components/Kit', then your own named exports.
| Component | Props | Notes |
|---|---|---|
Field |
id, label, description?, error? |
The control goes in the default slot and takes the same id; error is an alert |
Input |
invalid? |
v-model; other attributes pass through |
Textarea |
invalid? |
v-model |
Select |
options, id?, placeholder?, disabled?, invalid? |
v-model; options are { value, label } |
TimezoneSelect |
id?, disabled?, invalid? |
v-model: an IANA zone name |
Checkbox |
id, disabled? |
v-model: boolean; the label is the default slot |
Button |
variant? (primary, secondary, destructive), type? |
A submit button by default |
FormActions |
none | The row that ends a form |
Section |
title?, description? |
A card for a group of fields |
The kit's own checks refuse a generated page or shell file that imports UI from anywhere but @kit/ui.
Page header actions
<Page :actions="..."> takes the page's server action descriptors (Tey\Kit\Features\Core\Data\Action) and splits them with one rule, headerSplit (Kit/Action/headerSplit.ts): one filled primary, up to two outline secondaries, and a "More" menu with the rest, in descriptor order everywhere. The menu is the kit's action menu, so dialog and document intents, confirmations (typed ones too), disabled actions with their reason and the hiding of edit actions in a locked form all behave as in ActionBar. A non-navigating action is emitted as action when the page listens, else the header runs it as ActionBar does.
- Priority. An action may declare
priority:primary,secondaryoroverflow(the trailing constructor argument,nullby default). A declared priority always wins. If more than one action isprimary, the first stays primary and the later ones show as secondaries (a console warning in development). Secondaries beyond two go into More, in order. - Defaults. With no priority declared, three actions or fewer render exactly as today: through ActionBar, with the same markup as passing those descriptors to ActionBar in the
actionsslot (its placement rule and container fit decide). With more than three, the first non-destructive action is primary, the next two non-destructive ones are secondaries, and everything else goes into More. Navigation GETs count like any other action. A destructive action is never made primary or secondary by default. - Status. The
#statusslot, or thestatusprop ({ state: 'saved' | 'saving' | 'idle' | 'error', label }, a small icon and the label), sits before the actions in a mutedtext-smlive region the kit owns (role="status",aria-live="polite"), never a button. - Phone. Below
sm(a media query, not the container's width) only the primary stays visible; the secondaries move to the top of More. Abovesm, a header that is too narrow wraps, and the three-or-fewer ActionBar path still collapses as ActionBar does. - Mirror.
FloatingToolbartakes the sameactionsandstatusand splits them the same way, so the bottom pill repeats the header. Its non-navigating actions go toonPageAction(@page-action);onActionstays the handler for its clientitems. The Workbench's actions row splits itsactionsthe same way (three or fewer keep its ActionBar, the first filled), beside its save state. - Escape hatch. Whatever the
#actionsslot holds renders as is, after the split actions, with no rule applied.
The kit's TypeScript types
The kit's pages are typed with its data and view-model types, in global Tey.Kit.Features.* namespaces. The kit ships them, generated from its classes, in vendor/tey/kit/types/kit.d.ts. That's the supported source: add it to tsconfig.json's include, and it stays in step with the kit you have installed:
"include": ["resources/js/**/*", "vendor/tey/kit/types/kit.d.ts"]
A page of yours that types its props with them (defineProps<Tey.Kit.Features.SessionUi.ViewModels.WorkspaceDashboardViewModel>()) also needs the file in @vitejs/plugin-vue's script.globalTypeFiles, since Vue's compiler resolves those props at build time:
vue({ script: { globalTypeFiles: ['vendor/tey/kit/types/kit.d.ts'] } })
kit:status and kit:add check this (the shell feature's vite_global_types host step): the Vite config is read as text, never run, and is unmet, with that snippet, when it lists no script.globalTypeFiles, when the list lacks the kit's types, or, "can't verify", when there is no vite.config.{ts,js,mjs}, it can't be read, or globalTypeFiles is not an array literal. For a config the kit cannot read (a plugin factory, mergeConfig), check it yourself and record that with kit.host.vite_global_types = 'manual' (KIT_HOST_VITE_GLOBAL_TYPES=manual): the step then reads "verified manually". It is a fact you own, not an exemption. An app that writes the Tey.Kit namespaces itself (its own transformer's generated .d.ts) and lists that file in globalTypeFiles meets both this step and the tsconfig types step: kit:status reads the listed file (a relative path) and accepts it when it declares namespace Tey with namespace Kit directly inside (or namespace Tey.Kit). Listing it beside the kit's kit.d.ts is unmet: each type would be declared twice.
The shell feature's host steps point this out, along with the @kit/shell and @kit/ui aliases. Transforming the kit's src with your own transformer isn't needed, and a transformer that writes one flat module can't produce these namespaces. If yours already writes them (the global-namespace writer over vendor/tey/kit/src), leave the include out, since both would declare each type twice. The dashboard's view model is typed WorkspaceDashboardViewModel, so it never collides with an app's own DashboardViewModel.
A host that copies the file instead (resources/js/types/kit.d.ts) is told when a release outdates the copy, with the types it lacks. The kit's CI keeps types/kit.d.ts in step with its classes (composer test:kit-types; php bin/kit-types.php rewrites it).
Computed values and their working
ComputedValue shows a figure the server calculated and lets the reader open how it was reached. The server sends a Tey\Kit\Explanation\ExplanationDocument (Laravel Data), and each root ExplanationNode carries its display string, exact minor units, inputs with their sources, the rule applied, rounding and allocation. The kit validates the document's structure and refuses floats, dangling operands, unsafe links and oversized trees. Arithmetic, reconciliation and authorisation stay with the app. The browser only displays the strings it is given.
<ComputedValueScope scope-key="invoice" remember> <ComputedValueToggle /> <ComputedValue :node="explanation.roots[0]" label="Invoice total" placement="overlay" /> </ComputedValueScope>
placement is inline (the default), table-row or overlay (a popover, or a bottom sheet on a phone). ComputedValueRoot is the headless version, for your own markup. The Explanation* types come from @kit/ui. They are generated from the PHP classes by php bin/generate-explanation-ts.php, and --check fails when they are stale. Never edit the generated file by hand. A page view model that carries a list<ExplanationNode> or an ExplanationDocument types it as the kit's own type when the host registers Tey\Kit\TypeScript\ExplanationTypesExtension in its typescript-transformer config ($config->extension(new ExplanationTypesExtension)); without it spatie/laravel-typescript-transformer emits undefined for those props and the page has to cast. The generated types import from @kit/ui, so the host's tsconfig must resolve it.
Releasing
Before tagging a release, run php bin/catalog-history.php and commit resources/features/history.json. It records each tagged release's file hashes, which kit:adopt uses to recognise what a pre-lifecycle app installed, plus a current entry for the catalog being tagged. composer test:adopt-in-place (run in CI) fails when current no longer matches the live catalog, so any change to a feature's stubs or manifest regenerates it in the same PR. php bin/catalog-history.php --check runs the same comparison on its own. A change to a feature's stubs or manifest also changes its pin: php bin/catalog-pins.php prints every feature's current identity next to its pin and marks it ok or changed. Copy a changed one into src/Catalog.php and tests/StarterCatalogCheck.php, then regenerate the history. With --check (a CI step) it exits 1 while any pin is stale.