@jskit-ai/agent-docs 0.1.289 → 0.1.291

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/guide/agent/app-extras/assistant-conversation.md +7 -1
  2. package/guide/agent/app-setup/a-more-interesting-shell.md +3 -1
  3. package/guide/agent/app-setup/existing-application-migration.md +1 -1
  4. package/guide/agent/app-setup/material-3-migration.md +7 -4
  5. package/guide/agent/framework/data-access.md +200 -19
  6. package/guide/agent/framework/material-3.md +41 -37
  7. package/guide/agent/framework/sanity-checks.md +15 -12
  8. package/guide/agent/framework/ui-operations.md +3 -1
  9. package/package.json +1 -1
  10. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  11. package/patterns/minimal-foundation/example/package.json +4 -4
  12. package/patterns/shell-foundation/example/package.json +5 -5
  13. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  14. package/reference/autogen/PATTERN_INDEX.md +38 -38
  15. package/reference/autogen/packages/agent-runtime.md +4 -0
  16. package/reference/autogen/packages/assistant-core.md +4 -0
  17. package/reference/autogen/packages/kernel.md +1 -1
  18. package/reference/autogen/packages/resource-core.md +1 -0
  19. package/reference/autogen/packages/resource-runtime.md +27 -0
  20. package/reference/autogen/packages/shell-web.md +14 -0
  21. package/reference/autogen/packages/workspaces-core.md +0 -2
  22. package/reference/autogen/tooling/jskit-catalog.md +30 -10
  23. package/skills/jskit/references/data-access.md +200 -19
  24. package/skills/jskit/references/existing-application-migration.md +1 -1
  25. package/skills/jskit/references/heuristics/placements.md +1 -1
  26. package/skills/jskit/references/material-3.md +41 -37
  27. package/skills/jskit/references/pattern-index.md +38 -38
  28. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
  29. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +5 -5
  30. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  31. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
  32. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  33. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  34. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  35. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
  36. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
  37. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
  38. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  39. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  40. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
  41. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
  42. package/skills/jskit/references/sanity-checks.md +15 -12
  43. package/skills/jskit/references/ui-operations.md +3 -1
@@ -66,6 +66,19 @@ Local functions
66
66
  - `buildCatalog({ repoRoot, packagesRoot, outputPath })`
67
67
  - `main()`
68
68
 
69
+ ### `scripts/data-access-files.mjs`
70
+ Exports
71
+ - `ROOT`
72
+ - `lintIgnores({ governed, config })`
73
+ - `sourceFiles(root)`
74
+ Local functions
75
+ - `skipsDirectory(name)`
76
+ - `walk(root, directory, files)`
77
+ - `frameworkPackages(root)`
78
+ - `appEslint(root)`
79
+ - `lintConfigs(root, files)`
80
+ - `skippedPaths(files, skipped)`
81
+
69
82
  ### `scripts/data-access.mjs`
70
83
  Exports
71
84
  - `checkDataAccess({ projectRoot = process.cwd() } = {})`
@@ -73,7 +86,6 @@ Exports
73
86
  - `readTableResourceSources({ projectRoot = process.cwd() } = {})`
74
87
  Local functions
75
88
  - `escapeRegExp(text)`
76
- - `skipsDirectory(name)`
77
89
  - `blank(text)`
78
90
  - `maskStrings(code)`
79
91
  - `closingOffset(masked, start)`
@@ -98,16 +110,12 @@ Local functions
98
110
  - `readLintDisables(file, source, position)`
99
111
  - `readRuntimeCapabilities(file, { code, masked, position })`
100
112
  - `isError(setting)`
101
- - `appEslint(root)`
102
- - `checkLintConfig(root, files, baseline, report)`
113
+ - `checkLintConfig({ configs, error }, baseline, report)`
103
114
  - `checkCapabilities(baseline, uses, report)`
104
115
  - `readBaseline(root, report)`
105
116
  - `checkBaseline(baseline, excuses, scopes, report)`
106
- - `walk(root, directory, files)`
107
- - `frameworkPackages(root)`
108
117
  - `checkTables(resources, report)`
109
118
  - `checkSystemScopes(scopes, report)`
110
- - `sourceFiles(root)`
111
119
 
112
120
  ### `scripts/files.mjs`
113
121
  Exports
@@ -188,9 +196,8 @@ Local functions
188
196
  ### `scripts/material3.mjs`
189
197
  Exports
190
198
  - `MATERIAL3_RULES`
191
- - `checkMaterial3({ projectRoot = process.cwd(), paths = [], navigationOnly = false } = {})`
199
+ - `checkMaterial3({ projectRoot = process.cwd(), paths = [] } = {})`
192
200
  - `formatMaterial3Report(result)`
193
- - `formatNavigationReport(result)`
194
201
  Local functions
195
202
  - `hexPattern()`
196
203
  - `channelColour()`
@@ -201,8 +208,8 @@ Local functions
201
208
  - `isFrameworkPackage(projectRoot, directory)`
202
209
  - `walk(projectRoot, directory, files, wholeApp = false)`
203
210
  - `collectFiles(projectRoot, paths)`
204
- - `createReporter(file, source, { navigationOnly = false } = {})`
205
- - `checkCode(code, offset, { report, themed })`
211
+ - `createReporter(file, source)`
212
+ - `checkCode(code, offset, context)`
206
213
  - `checkScript(code, offset, context)`
207
214
  - `checkCss(css, offset, context)`
208
215
  - `checkDeclaration(declaration, context)`
@@ -386,10 +393,23 @@ Local functions
386
393
  - `seconds(ms)`
387
394
  - `readTestTimeBounds(cwd)`
388
395
 
396
+ ### `scripts/ui-components.mjs`
397
+ Exports
398
+ - `checkRemovedProps(tags, script, removedProps, report)`
399
+ - `checkVuetifyByNameAttribute(tag, entry, context)`
400
+ - `checkVuetifyByNameCode(code, offset, context)`
401
+ - `readRemovedProps(root)`
402
+ - `readVuetifyComponents(root)`
403
+ Local functions
404
+ - `readExportNames(file, names = new Set(), seen = new Set())`
405
+ - `readComponentImports(script)`
406
+ - `reportByName({ report, vuetifyComponents }, offset, name, how)`
407
+
389
408
  ### `scripts/verify-packages.mjs`
390
409
  Exports
391
410
  - `main()`
392
411
  - `validateApiSummarySurfaces(packageJson)`
412
+ - `validateRemovedProps(packageJson)`
393
413
  - `validateCapabilityClosure(packages, { builtinCapabilities = BUILTIN_CAPABILITIES } = {})`
394
414
  - `validateMigrations(packageRecord, migrationOwners)`
395
415
  - `validateProviderExport(value, label)`
@@ -30,8 +30,9 @@ through its handle's calls, and every call takes the call's `options` last:
30
30
  | --- | --- |
31
31
  | `query({ filters, sort, page, include }, options)` | one page: `{ records, page }` |
32
32
  | `queryAll({ filters, sort, include }, options)` | every matching row, for jobs, id lists and one parent's rows |
33
+ | `queryPages({ filters, sort, include }, options)` | every matching row, a page at a time, for work over many rows |
33
34
  | `get(id, params, options)` | one record |
34
- | `aggregate({ measures, groupBy, filters, sort }, options)` | totals, grouped by fields or date buckets |
35
+ | `aggregate({ measures, groupBy, filters, sort, page }, options)` | totals, grouped by fields or date buckets |
35
36
  | `create(data, options)`, `findOrCreate({ id, ...data }, options)` | one record; a keyed one, created once |
36
37
  | `patch(id, changes, options)`, `delete(id, options)` | one record |
37
38
  | `createMany(rows, options)` | many records, or an upsert with `onConflict` |
@@ -55,6 +56,14 @@ function totalsByMonth(options) {
55
56
  }
56
57
  ```
57
58
 
59
+ A grouped `aggregate` answers its groups from one statement, at most
60
+ `pageLimits().request` of them (the resource's `queryMaxLimit`). When there
61
+ are more it throws a 400 coded `RESOURCE_GROUP_LIMIT` instead of answering
62
+ part of them. Narrow the filters, or read the groups in pages, `page: { size,
63
+ number }` with `number` from 1, until a page answers fewer than `size` rows.
64
+ Page numbers are offsets, so read every page before work that changes what
65
+ the filters match.
66
+
58
67
  ## Words
59
68
 
60
69
  | Word | Meaning |
@@ -152,6 +161,44 @@ whole number exactly, so json-rest-api answers those digits as text rather
152
161
  than a rounded number. A value that can grow that large gets a decimal field
153
162
  (`precision` and `scale`), whose digits stay text.
154
163
 
164
+ A `type: "blob"` field holds bytes the process reads and writes whole, so it
165
+ names `maxBytes`, the most bytes a value holds, and its column holds that much
166
+ too: MySQL's and MariaDB's `BLOB` holds 64 KiB and `MEDIUMBLOB` 16 MiB, while
167
+ PostgreSQL's `bytea` and SQLite's `BLOB` hold more than any limit you would
168
+ name. A blob takes no `search`, `indexed`, `unique`, `sortable`, `defaultTo` or
169
+ `belongsTo`. A write takes a Buffer or a Uint8Array of at most `maxBytes`, or
170
+ null, and refuses anything else with a validation error on the field (422)
171
+ before any statement, a transaction's or a lock's included. A read leaves a
172
+ blob out unless its `fields` names it, and answers it as a Buffer, holding at
173
+ most 16 MiB of the blobs it names, or one record's: `get(id, { fields:
174
+ ["image"] }, options)` and `lock()` read one record's; a page of `query()` and
175
+ the ids of `lockMany()` are refused past the records whose `maxBytes` fit 16
176
+ MiB, and each page of `queryPages()` holds no more. `queryAll()`, `lockWhere()`
177
+ and `claim()` read every matching row at once, so they refuse to name a blob,
178
+ and no read names an included table's blob, since an include holds any number
179
+ of records: read it through that table's handle. A join source carries no
180
+ blob. JSON holds no binary, so a JSON:API read and a CRUD contract refuse a
181
+ blob, and a route of its own serves one as the response body. A file people upload (a photo, a
182
+ document) goes to storage through `runtime.uploads` and the table keeps its
183
+ key; a blob is for the few bytes the record itself needs, such as a thumbnail
184
+ or a signature.
185
+
186
+ ```js
187
+ const signatureResource = defineTableResource({
188
+ name: "signatures",
189
+ tableName: "signatures",
190
+ ownership: "workspace",
191
+ schema: {
192
+ signedBy: { type: "string", required: true },
193
+ // MEDIUMBLOB on MySQL and MariaDB
194
+ image: { type: "blob", maxBytes: 256 * 1024, required: true }
195
+ }
196
+ });
197
+
198
+ // One signature's image, read where a route answers it.
199
+ const imageOf = async (id, options) => (await signatures.get(id, { fields: ["image"] }, options)).image;
200
+ ```
201
+
155
202
  A package whose features own tables or declare `systemScope` lists
156
203
  `runtime.resources` in its descriptor's `capabilities.requires`, or boot
157
204
  refuses it; the feature never requires it itself, since it receives its
@@ -197,7 +244,17 @@ A filter on another table's field (`searchSchema: { supplierName: { type:
197
244
  "string", actualField: "suppliers.name" } }`) needs that field declared
198
245
  `indexed: true` on its own table resource, with a database index on its column,
199
246
  even when the join is on the other table's id; `own()` refuses it at startup
200
- otherwise, naming both.
247
+ otherwise, naming both. That `indexed: true` is the other table's owner letting
248
+ filters search the field. The filter reads that table as the table's own read
249
+ in the caller's scope: its ownership, its owner's row policy and the policies
250
+ contributed to it decide which of its rows can match, in `query`, `queryAll`,
251
+ `queryPages` and `aggregate` alike, and each row matches once, through a
252
+ has-many path too (suppliers filtered by their items' names). `own()` also
253
+ refuses two filters no scope guards: one that reads a `system` table from a
254
+ table that is not `system`, since a request would read it, and one on a field
255
+ the other table hides, which would tell a caller which rows hold a value it
256
+ may not read. For either, the other table's owner answers the ids it matches
257
+ through its owner API, and the filter takes the list: `filters: { supplierIds }`.
201
258
 
202
259
  A table keyed by a `uuid` or `string` primary key takes its ids as text; an
203
260
  `owns` entry's `normalizeId` is only for an id written some other way.
@@ -313,7 +370,11 @@ request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
313
370
  `{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
314
371
  `reason`, for background work. A job names a user only as `actorId`, the
315
372
  person it works for ([below](#background-work)), so never read a missing actor
316
- as an anonymous request.
373
+ as an anonymous request. The owner's policy also receives the capabilities its
374
+ feature requires (and its optional ones), by local name, as `requires`, the
375
+ same ones its setup receives: a policy that needs another feature's answer
376
+ declares that feature's capability in `requires` and reads `requires.<name>`.
377
+ It runs on every read, so what it asks there must be cheap.
317
378
 
318
379
  ```js
319
380
  // good: one predicate on the row being read
@@ -487,8 +548,10 @@ Every filter takes `id`, one id or a list, so `patchWhere({ id: ids },
487
548
  changes, options)` and `deleteWhere({ id: ids }, options)` change a known
488
549
  list. Each checks every row against the resource first, reads which of the
489
550
  rows the caller may touch, then writes each chunk with one statement, in one
490
- transaction. Audits and change events run once for the batch, so the
491
- statements are the same for 10 rows as for 200. `patchWhere` and
551
+ transaction. A chunk is 500 rows unless the call passes `chunkSize`, which is
552
+ at least 100: a smaller chunk would send a statement every few rows, as a loop
553
+ does, and is refused before any statement. Audits and change events run once
554
+ for the batch, so the statements are the same for 10 rows as for 200. `patchWhere` and
492
555
  `deleteWhere` check the scope and filters on each row once they hold its lock,
493
556
  so a row gone meanwhile, or changed so that it no longer matches, is skipped,
494
557
  and they take no `missing`. That makes them a compare-and-set on their filters
@@ -559,6 +622,31 @@ or a counter row taken with `lock(id)`. Every lock needs a transaction; SQLite
559
622
  runs one transaction at a time, so there is no row lock to take. Take locks in
560
623
  one order on every path, a parent before its children.
561
624
 
625
+ A lock answers only the rows it locks, with the fields `fields` names, and
626
+ refuses `include` before any statement. Read the locked rows' related records
627
+ after the lock, in the same transaction: `queryAll({ filters: { id: ids },
628
+ include }, txOptions)`, or the related table's read by id list. On PostgreSQL
629
+ every statement sees what had committed when it began, so that read is current.
630
+ On MySQL and MariaDB a plain read inside a transaction sees the snapshot of the
631
+ transaction's first plain read, and a lock that waited does not move it. So
632
+ take the lock before any plain read in the transaction: the reads after it then
633
+ see what the lock's previous holder committed. A related row the work decides
634
+ on (is this seat already booked?) is locked itself, or read after a lock
635
+ that every writer of it takes first.
636
+
637
+ ```js
638
+ // good: the lock is the transaction's first read, so the bookings read after it are current
639
+ const reserve = (seatId, options) => seats.withTransaction(options, async (txOptions) => {
640
+ await seats.lock(seatId, { ...txOptions, fields: ["status"] });
641
+ const taken = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
642
+ return taken.length > 0 ? null : bookings.create({ seatId }, { ...txOptions, returning: "id" });
643
+ });
644
+
645
+ // bad: the read before the lock fixed the snapshot, so a booking committed while the lock waited is missed
646
+ const free = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
647
+ await seats.lock(seatId, txOptions);
648
+ ```
649
+
562
650
  On MySQL and MariaDB a locking read locks every row it examines, so two rules
563
651
  apply there. Lock ids taken from your own scoped reads, never a list straight
564
652
  from a request: every id listed is locked, a hidden one too, before it is left
@@ -671,8 +759,10 @@ async function activeDays(memberIds, options) {
671
759
  other features, answers `{ query, column }`: the caller's scoped rows under
672
760
  that alias (ownership, the row policy and the filters applied) with the
673
761
  columns a read answers and the ownership fields (`workspaceId`, `userId`),
674
- hidden or not, as join keys; never another hidden column. `column(field)`
675
- names each under the alias. The builder comes wrapped, since a builder is
762
+ hidden or not, as join keys; never another hidden column, and never a blob,
763
+ so a report's `select("k.*")` holds no unbounded bytes. `column(field)`
764
+ names each under the alias. A report on the owner's own `scopedQuery()`
765
+ names the columns it selects, never a blob's. The builder comes wrapped, since a builder is
676
766
  thenable and an async function would run it. The report shapes it (select,
677
767
  group), names it with `.as()` and joins it, so every owner's scope and row
678
768
  policy stay applied. The report lives with the main table's owner, or in a
@@ -702,6 +792,25 @@ async function activeDays(memberIds, options) {
702
792
  }
703
793
  ```
704
794
 
795
+ - A report that asks which of its rows have, or lack, another owner's rows
796
+ puts that owner's join source in `whereExists` or `whereNotExists`, tied to
797
+ its own row with `whereColumn`; one that counts them joins the source grouped,
798
+ as `projectSummary` does. Rows matched by a related row's value ("items
799
+ whose supplier is named Acme") need no report: a cross-table search filter
800
+ answers them in a plain read ([Layer 1](#layer-1-the-table-resource)).
801
+
802
+ ```js
803
+ // Knex: report - the caller's projects with no task the caller sees
804
+ async function idleProjects(options) {
805
+ const task = await tasks.joinSource({ alias: "k" }, options);
806
+ const { query } = await projects.scopedQuery({ alias: "p" }, options);
807
+ const projectId = projects.column("id", "p");
808
+ return query
809
+ .whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), projectId))
810
+ .select({ id: projectId, name: projects.column("name", "p") });
811
+ }
812
+ ```
813
+
705
814
  - When each owner can answer on its own (keys in, grouped totals out), the
706
815
  report is a feature of its own that calls each owner's method and merges the
707
816
  answers in memory: one statement per owner, never a call per row.
@@ -721,11 +830,26 @@ async function activeDays(memberIds, options) {
721
830
  another field) or `include`, grouped in memory. Never a query per row, however
722
831
  the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
723
832
  item).
724
- - `queryAll` reads every matching row, in cursor pages that skip and repeat
725
- none. Use it for id lists, one parent's rows and jobs: a read its callers
833
+ - `queryAll` reads every matching row, in cursor pages that each start after
834
+ the sort values of the page before, so no row is skipped or read twice
835
+ unless a write moves a row's sort value (the id, by default) past the page
836
+ while the read runs. Use it for id lists, one parent's rows and jobs: a read its callers
726
837
  treat as complete, which a `query` would silently cut at one page. In a
727
- request it stops past the request row limit (10,000 rows) with a 400 coded
728
- `RESOURCE_ROW_LIMIT`.
838
+ request it stops past the request row limit (`pageLimits().requestRows`,
839
+ 10,000 rows) with a 400 coded `RESOURCE_ROW_LIMIT`, and never answers part
840
+ of the rows. A job reads without that limit and holds every row in memory.
841
+ - Work over many rows reads `queryPages` and handles one page at a time:
842
+ `for await (const records of handle.queryPages(params, options))`. Each
843
+ page is one statement of up to `pageLimits().server` rows (fewer when its
844
+ fields name blobs, so it holds at most 16 MiB of them), plus the reads of
845
+ its includes and of the hidden fields it names, and starts after the sort
846
+ values of the last row of the page before. So the work may change what the
847
+ filters match as it goes, and no row still to come is skipped or read twice,
848
+ as long as it leaves the sort fields' values alone (the id, by default): a
849
+ row whose sort value moves past the page is read again or not at all. In a
850
+ request it throws at the same row limit, before the page that passes it.
851
+ `expectFlatQueries` allows each later page's own statements, as it allows
852
+ a many-row write's later chunks; work the path does for each page counts.
729
853
 
730
854
  ```js
731
855
  // good: one page of invoices, then all of that page's lines in one read
@@ -741,6 +865,11 @@ for (const invoice of records) {
741
865
  // good: a delivery job reads every active subscriber
742
866
  const subscribers = await subscriptions.queryAll({ filters: { newsletterId, status: "active" } }, options);
743
867
 
868
+ // good: a job over a table of any size marks each page as it reads it
869
+ for await (const records of reminders.queryPages({ filters: { status: "due" } }, txOptions)) {
870
+ await reminders.patchWhere({ id: records.map((reminder) => reminder.id) }, { status: "sent" }, txOptions);
871
+ }
872
+
744
873
  // bad: the first page only, whatever its size
745
874
  const { records: firstPage } = await subscriptions.query({ filters: { newsletterId, status: "active" }, page: { size: 20 } }, options);
746
875
  ```
@@ -832,14 +961,21 @@ const SettlementFeature = defineFeature({
832
961
  request's `caller.userId`, so a job for a person sees what that person may
833
962
  see, and a job for no one its whole scope. Work on one person's rows only
834
963
  takes their scope (`forUser`, `forWorkspaceUser`) or filters its reads.
835
- - A job finds its units with one `aggregate` grouped by the ownership field,
964
+ - A job finds its units with an `aggregate` grouped by the ownership field,
836
965
  which a system context may group by (and count distinct) even when the table
837
- hides it, then runs each unit scoped to it. A request's aggregate still
838
- refuses a hidden field:
966
+ hides it, then runs each unit scoped to it. It reads the units in pages, all
967
+ of them before the first runs, since settling a unit changes what the
968
+ filters match. A request's aggregate still refuses a hidden field:
839
969
 
840
970
  ```js
841
971
  async function settleDue(options) {
842
- const due = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" } }, options);
972
+ const size = cohorts.pageLimits().request;
973
+ const due = [];
974
+ for (let number = 1; ; number += 1) {
975
+ const page = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
976
+ due.push(...page);
977
+ if (page.length < size) break;
978
+ }
843
979
  for (const { workspaceId } of due) {
844
980
  await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
845
981
  }
@@ -948,7 +1084,10 @@ participants) reads them with `users.profiles.listByIds(ids, options)`: every
948
1084
  profile in one call, however many ids, never `query` pages of 100 walked by
949
1085
  hand or one `findById` per person. `users.settings.listByUserIds(ids, options)`
950
1086
  does the same for their settings, and `workspaces.listByIds(ids, options)` for
951
- workspaces.
1087
+ workspaces. A report that reads people, their settings, workspaces or
1088
+ memberships beside its own rows joins `joinSource({ alias, filters },
1089
+ options)` of `users.profiles`, `users.settings`, `workspaces` or
1090
+ `workspaces.memberships` ([Layer 5](#layer-5-reports-the-resource-cannot-express)).
952
1091
 
953
1092
  An app that had a table resource of its own over a JSKIT table (such as
954
1093
  `users`) deletes it: its CRUD contract is built on the package's table resource
@@ -1001,6 +1140,38 @@ named with no value, such as `lockVersion: record.lockVersion` on a field
1001
1140
  called `version`, is refused (`options.lockVersion is undefined`), never run
1002
1141
  without it.
1003
1142
 
1143
+ ### Capability cycles
1144
+
1145
+ A capability cycle is a ring of requires: feature A requires a capability that
1146
+ feature B provides while B, directly or through others, requires one that A
1147
+ provides. An `optional` require closes a ring as a required one does. The
1148
+ kernel sets features up in the order their requires give, so it cannot start a
1149
+ ring: boot fails before any setup, naming it,
1150
+ `Provider capability cycle detected: children -> parents -> children.`
1151
+ `startTestFeatures` orders features the same way, so a feature test meets the
1152
+ ring before the server does. The same ring between packages, each listing the
1153
+ other in its `package.json` dependencies (as importing the other's shared
1154
+ table resource needs), fails as
1155
+ `Package dependency cycle detected: @local/jobs -> @local/checklists -> @local/jobs`.
1156
+
1157
+ Data access makes them easy to write: a parent's owner reads its children
1158
+ through their owners while each child's owner checks or advances the parent.
1159
+ Keep every require pointing one way:
1160
+
1161
+ - The table nearest the root requires none of the owners below it. It provides
1162
+ a small owner API, and the owners below require that.
1163
+ - Work that reads several owners together (a parent with its children, a
1164
+ report across them) is a feature of its own that requires each of them, as
1165
+ above and in [Layer 5](#layer-5-reports-the-resource-cannot-express).
1166
+ - A package imports another package's shared table resource only when that
1167
+ package does not depend on it back. When two packages need each other's
1168
+ definitions, move those table resources into a package of their own that
1169
+ holds only shared definitions and depends on neither.
1170
+ - Never break a ring with a value handed over later (a setter, a capability
1171
+ looked up at call time): a call's destination then depends on what ran
1172
+ before. A setter kept until the split carries typed features' `cycle:`
1173
+ exception ([Optional tokens](typed-features.md#optional-tokens)).
1174
+
1004
1175
  ## Check and judge
1005
1176
 
1006
1177
  Before calling data work done, apply the
@@ -1024,7 +1195,11 @@ test("allocation is flat", () => expectFlatQueries({ environment: process.env, k
1024
1195
  `expectFlatQueries` (from `@jskit-ai/resource-runtime/test`) seeds each size,
1025
1196
  counts the statements the path sends, and fails when a larger size sends more,
1026
1197
  with the counts and the statements repeated most; the later chunks a many-row
1027
- write reports are allowed. `expectScoped` runs the path as owner A before and
1198
+ write reports, and the statements of each later page `queryPages()` reads, are
1199
+ allowed. It counts only the path's own statements, those
1200
+ `run` and the async work it starts send, so another test or a background loop
1201
+ using the same Knex meanwhile changes nothing; work a loop started earlier does
1202
+ on the path's behalf is not counted either. `expectScoped` runs the path as owner A before and
1028
1203
  after seeding owner B and fails when the answer changes, so it catches a report
1029
1204
  that sums, counts or joins B's rows, not only one that returns them; it also
1030
1205
  fails when the path changed or removed a row of B's. The path runs twice, so it
@@ -1205,5 +1380,11 @@ it. Check and judge, for each file:
1205
1380
  `/* eslint ... */` config that sets one, outside client code;
1206
1381
  - an app file that the app's ESLint config lints without every data-access
1207
1382
  rule as an error (`"jskit/no-direct-knex": "off"`, `dataAccessConfig(files,
1208
- "off")`), or server code (`src/server`, `src/shared`, `server/`) that it
1209
- ignores, until `infrastructure` lists that file and rule.
1383
+ "off")`), or server code that it ignores, until `infrastructure` lists
1384
+ that file and rule. Server code is what the base config lints, at the app's
1385
+ root or a package's: its files (`server.js`), `src/` (an app's whole `src/`,
1386
+ `src/jobs/` with it), `server/`, `scripts/`, `bin/` and `config/`.
1387
+
1388
+ It reads none of what the app's ESLint config ignores outside server code,
1389
+ such as a vendored copy of old code kept beside the app, since no lint reads
1390
+ it either, and lists each directory and file it skipped.
@@ -208,7 +208,7 @@ write goes through that table's handle
208
208
  | a field's `default:` | `defaultTo:` (`default` is refused, since json-rest-api never read it) |
209
209
  | a `createdAt` or `updatedAt` default of now | nothing: drop it, since every handle write stamps them |
210
210
  | a CRUD contract that showed every column | a contract shows only its `fields`, so list every field the API showed |
211
- | reading every row | `queryAll`, which refuses above 10,000 rows, so page with `query()` |
211
+ | reading every row | `queryAll`, which refuses above 10,000 rows in a request; a job over more reads `queryPages()` a page at a time |
212
212
  | an app's own wrappers around old calls | the handle method they wrapped, then delete the wrapper |
213
213
 
214
214
  Handles answer records, `{ id, ...fields }`, with a belongs-to field as the
@@ -60,7 +60,7 @@ Runtime behavior:
60
60
  - Runtime resolves the component token as `entry.componentToken || variant.renderers[entry.kind]`.
61
61
  - Entries without a resolvable component token do not render.
62
62
  - `when()` receives placement context including `app`, `surface`, `target`, `layoutClass`, runtime context, local outlet context, and context contributors.
63
- - The standard `ShellLayout` uses Material's 12px outer item inset and one `navigationItemSpacing` value (12px by default) for the icon-to-label gap and widest label's logical-end gap. It remeasures its content-aware width after placement, locale, font, surface, spacing, and layout changes. The surface label belongs once in the top app bar; do not add or restore a duplicate drawer subheader. Use `drawerWidth` only when a product needs a fixed width; do not add consumer `.v-navigation-drawer` overrides.
63
+ - The standard `ShellLayout` uses Material's 12px outer item inset and one `navigationItemSpacing` value (12px by default) for the icon-to-label gap and widest label's logical-end gap. Its drawer fits its labels and identity by default, up to Material 3's 360dp, and remeasures that width after placement, locale, font, surface, spacing, and layout changes. The surface label belongs once in the top app bar; do not add or restore a duplicate drawer subheader. Use `drawerWidth` only when a product needs a fixed width (a number) or Material 3's full 360dp (`"material"`); do not wrap `ShellLayout` or add consumer `.v-navigation-drawer` overrides to narrow it.
64
64
  - On medium and expanded layouts, closing the standard drawer produces an 80px Material navigation rail by default. Expanded and collapsed icons share one centreline. Use `railWidth` to configure an intentionally denser rail, or `desktopDrawerClosedMode="hidden"` only when another discoverable navigation surface exists.
65
65
  - The shell link renderers own rail centring, selected-state treatment, and opaque theme-aware tooltips. Applications should keep supplying the same semantic placement records rather than creating rail-only links or tooltip CSS.
66
66
 
@@ -179,9 +179,9 @@ Exactly this, and nothing more:
179
179
  `createVuetify(createMaterialVuetifyOptions(theme))` with
180
180
  `createMaterialVuetifyOptions` from `@jskit-ai/material-web/client`, importing
181
181
  `@jskit-ai/material-web/client/material.css`. JSKIT generates the light and
182
- dark schemes from the seed at startup (Material Color Utilities
183
- `SchemeTonalSpot`, every role in kebab-case such as `surface-container-low`,
184
- plus success, warning and info with their containers), with
182
+ dark schemes from the seed at startup (every role in kebab-case such as
183
+ `surface-container-low`, plus success, warning and info with their
184
+ containers), with
185
185
  `defaultTheme: "system"` so the person's light, dark or system preference
186
186
  applies. The `md3` blueprint and JSKIT's Material 3 Vuetify defaults live in
187
187
  material-web; an app keeps no defaults file and never hand-picks a colour. The
@@ -205,6 +205,12 @@ Exactly this, and nothing more:
205
205
  Symbols has no brand marks (Google, GitHub, a payment network): a brand's
206
206
  logo is the brand's own artwork, a content image or SVG used as its owner
207
207
  publishes it, not an icon alias.
208
+ - **Components:** a Vuetify component chosen at run time is imported and
209
+ passed: `import { VDialog } from "vuetify/components"`, then `:is="VDialog"`
210
+ or `h(VDialog)`. A JSKIT app registers none globally, so one named as a
211
+ string (`resolveComponent("v-dialog")`, `is="v-dialog"`) renders nothing
212
+ (`ui/vuetify-by-name`). A prop a JSKIT package lists as removed (its
213
+ `jskit.metadata.ui.removedProps` and CHANGELOG) fails `ui/removed-prop`.
208
214
 
209
215
  ## Exact sizes
210
216
 
@@ -254,12 +260,10 @@ dialog, chip or FAB.
254
260
  tooltip), and `primary`, `to`, `replace`, `href` (with `download` for a
255
261
  file, such as a CSV export), `disabled`, `loading` or `color` (`error` for a
256
262
  destructive one); attributes such as `target` reach its button. On compact
257
- widths the bar keeps one trailing item after the title: the single
258
- `primary` action when the page has one (a form's Save), otherwise its own
259
- item nearest the corner (a top-right placement such as the account avatar).
260
- Every other action and the bar's other items (a notification bell, tools)
261
- go to its "More actions" menu, where `overflow` actions always are. Medium
262
- and wider bars show them all.
263
+ widths the bar shows the single `primary` action (a form's Save) and puts
264
+ the other actions, and the top-right items that don't fit beside the title,
265
+ in its "More actions" menu, where `overflow` actions always are; medium and
266
+ wider bars show them all.
263
267
  - **Window size classes:** compact under 600 (navigation bar), medium 600–839
264
268
  (navigation rail), expanded 840–1199 and large from 1200 (drawer), read from
265
269
  `useShellWindowSizeClass()`
@@ -272,12 +276,17 @@ dialog, chip or FAB.
272
276
  starter registers it as `local.main.ui.section-tab-link-item`), and the page
273
277
  draws one tab row, `display: flex; overflow-x: auto` over a 1dp
274
278
  `outline-variant` bottom border. `ShellTabLinkItem` is the navigation bar's
275
- destination; placed in a page it draws a section tab.
279
+ destination; placed in a page it draws a section tab. material.css draws an
280
+ app's own `v-bottom-navigation` the same way, keeping its colour props.
276
281
 
277
282
  ## Colour
278
283
 
279
284
  - Use semantic roles through Vuetify props, classes and
280
285
  `rgb(var(--v-theme-<role>))`; never a raw colour value in a component.
286
+ - Text in an `on-*` role uses its class or a `color` prop
287
+ (`class="text-on-surface-variant"`, `<v-icon color="on-surface-variant">`);
288
+ an app colour's is `class="on-partner"`, which `bg-partner` already sets.
289
+ Never lower text emphasis with an opacity or `text-medium-emphasis`.
281
290
  - Status chips use `color="success"` (or `warning`, `info`, `error`) or the
282
291
  container roles (`success-container` with `on-success-container`), and each
283
292
  status meaning has its own hue. Never communicate status, selection, validation, or permission through
@@ -310,9 +319,11 @@ dialog, chip or FAB.
310
319
  actions in the bar's overflow menu, never in the content; the record's
311
320
  next workflow step is the one filled button, shown in context rather than a
312
321
  row of mostly disabled actions.
313
- - **Form:** the bar's close (X) cancels and Save is its filled action. Fields
314
- use `hideDetails: "auto"`. Nothing moves under a finger: a multi-select chip
315
- field keeps its input width when it loses focus. A pick-one-of-N choice is a
322
+ - **Form:** the bar's close (X) cancels and Save is its filled action. A
323
+ field keeps its message line (no `hide-details`, no margin under it), so an
324
+ error appears in place; a field outside a form (search, filter) sets
325
+ `hide-details`. Nothing moves under a finger: a multi-select
326
+ chip field keeps its input width when it loses focus. A pick-one-of-N choice is a
316
327
  segmented button (`v-btn-toggle`, drawn by JSKIT, with 12dp segment sides as
317
328
  in any `v-btn-group`) with `role="radiogroup"` and
318
329
  `role="radio"`/`aria-checked` buttons.
@@ -346,11 +357,9 @@ dialog, chip or FAB.
346
357
  field. A user-triggered command uses JSKIT's shared action
347
358
  feedback/snackbar path. Never insert a transient command-error alert above
348
359
  page content where it shifts the working layout.
349
- - A snackbar never covers the FAB or the actions it reports on. `ShellErrorHost`
350
- centres it above the navigation bar on compact windows and starts it at the
351
- content's start edge, at most half its width, on wider ones. A FAB takes
352
- layout room (`<v-fab app layout>`, as `ShellPrimaryAction`'s does), so the
353
- snackbar shows above it.
360
+ - A snackbar never covers the FAB or the actions it reports on: `ShellErrorHost`
361
+ places it, and a FAB takes layout room (`<v-fab app layout>`, as
362
+ `ShellPrimaryAction`'s does), so the snackbar shows above it.
354
363
 
355
364
  ## Adaptive layout
356
365
 
@@ -366,16 +375,14 @@ Two commands read application source (no server, no browser) and print each
366
375
  finding with its file, line, rule and the section of this page:
367
376
 
368
377
  ```bash
369
- npx jskit check # in every app's verify: fails on any nav/* rule
370
- npx jskit check m3 # the whole app: src/ and packages/
378
+ npx jskit check # in every app's verify: fails on any nav/*, m3/* or ui/* finding
379
+ npx jskit check m3 # these rules alone, the whole app: src/ and packages/
371
380
  npx jskit check m3 src/pages/home/books # a section
372
381
  npx jskit check m3 $(git diff --name-only --diff-filter=d main) # changed files
373
382
  ```
374
383
 
375
- Plain `jskit check` fails on the [navigation rules](#forbidden-in-app-code).
376
- `jskit check m3` lists those and the visual rules below. The visual rules are a
377
- report until every JSKIT app has been converted to them; the JSKIT release after
378
- that makes plain `jskit check`, and so every app's verify, fail on them too:
384
+ Both fail on the [navigation rules](#forbidden-in-app-code) and on these;
385
+ nothing excuses a `ui/*` finding:
379
386
 
380
387
  | Rule | Finds |
381
388
  |---|---|
@@ -385,12 +392,12 @@ that makes plain `jskit check`, and so every app's verify, fail on them too:
385
392
  | `m3/outlined-card` | `variant="outlined"` or `border` on a card or sheet |
386
393
  | `m3/mdi-icon` | `mdi-*` icon strings and `@mdi/js` imports |
387
394
  | `m3/fixed-button-height` | `height`/`min-height` on buttons, in props, styles or CSS |
395
+ | `ui/vuetify-by-name` | a Vuetify component named as a string for Vue to resolve |
396
+ | `ui/removed-prop` | a prop the installed JSKIT package lists as removed from that component |
388
397
 
389
398
  Tests, fixtures, server code and migrations are not checked. A clean check is
390
- necessary, not sufficient: it cannot see layout, spacing or contrast. A
391
- [UI pass](ui-pass.md) is the guard for those, because it looks at every
392
- affected screen at three widths in light and dark; an editor that runs one
393
- automatically after each plan keeps that guard on every change.
399
+ necessary, not sufficient: layout, spacing and contrast need a
400
+ [UI pass](ui-pass.md), every affected screen at three widths in light and dark.
394
401
 
395
402
  ### Exceptions
396
403
 
@@ -399,16 +406,13 @@ Take the approved route first; none needs an exception:
399
406
  - **Brand or partner colours** are theme custom colours, used by name:
400
407
  `colors: { partner: "#0A7D3E" }` in `src/theme/index.js`, then
401
408
  `<v-chip color="partner">`. A hex string is harmonized toward the seed; a
402
- colour that must keep its hue is `{ value: "#F5C518", blend: false }`
403
- (Material Theme Builder's Blend switched off), with the same four roles.
409
+ colour that must keep its hue is `{ value: "#F5C518", blend: false }`, with
410
+ the same four roles.
404
411
  - **Content colours**, colours that are content rather than UI, keep one
405
- value in both schemes: an identity palette a person picks and the server
406
- stores, avatar and illustration artwork, brand marks (a language's
407
- file-type tint), fixed chart series, QR codes, colours written into
408
- exported files. They are named exports of plain hex values in
409
- `src/theme/palettes.js`, used as they are, never for UI chrome (a theme
410
- colour is toned per scheme, so it can't keep an identity the same).
411
- Components read them through bindings:
412
+ value in both schemes: an identity palette a person picks, artwork, brand
413
+ marks, fixed chart series, QR codes, colours in exported files. They are
414
+ named exports of plain hex values in `src/theme/palettes.js`, used as they
415
+ are, never for UI chrome. Components read them through bindings:
412
416
 
413
417
  ```vue
414
418
  <script setup>