@lessly/sdk-app 63.0.0 → 63.2.0

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.
@@ -234,6 +234,32 @@ method cannot drift the day an operation is re-levelled. The accessor path is a
234
234
  lossy view of the key, so never rebuild `operationKey` from it either — read it
235
235
  off the method.
236
236
 
237
+ ### Reading a level without a method
238
+
239
+ Sometimes there is no method to read `op.level` from: an App test double that has
240
+ to answer `can()` for a key, a runtime probe over keys it was handed. The answer
241
+ is still not a literal and still not a table in the App — `@lessly/sdk-app`
242
+ exports the catalog's own level map:
243
+
244
+ ```ts
245
+ import { operations, accessReason } from '@lessly/sdk-app';
246
+
247
+ operations['mail_domain_create']; // 'admin'
248
+ operations['analytics_insight_delete']; // 'write'
249
+ ```
250
+
251
+ `operations` is `Readonly<Record<string, ToolLevel>>`, keyed by operation key. It
252
+ is regenerated at publish time from the live catalog, so it re-levels with the
253
+ platform exactly as the generated methods do — which is the whole reason it may
254
+ be read where a hand-written table may not (APP-012).
255
+
256
+ A key the map does not carry answers `undefined`, and that is the case
257
+ `accessReason`'s string form already covers: pass the bare key and get
258
+ *"Requires access to `<key>`. Ask an admin of this product."* — no level invented,
259
+ no `level:undefined` in front of a user. Where you do hold a method, keep reading
260
+ `op.level` off it: the map is for the keys no method covers, not a second way to
261
+ do what the method already does.
262
+
237
263
  ### Your App mounts its own `TooltipProvider`
238
264
 
239
265
  Mount **one `TooltipProvider` per App canvas** — at the root of your remote, as in
@@ -358,6 +384,17 @@ it is not a judgement call you make silently — write it into your App's spec a
358
384
  named exception with the reason. An ungated write that nobody wrote down is
359
385
  indistinguishable from one nobody thought about.
360
386
 
387
+ ### One reason for a surface-wide gate
388
+
389
+ Where a gate disables a *capability across a whole surface* — dragging any node on
390
+ a canvas, a bulk action over a list or a grid — the reason goes on the control
391
+ that **owns** the capability: the toggle, the toolbar button, the surface header.
392
+ Not on every affected element. Thirty identical focusable wrappers are thirty
393
+ extra tab stops and one sentence repeated thirty times; that is noise, not thirty
394
+ explanations. Keyboard reachability (below) is satisfied on the owning control,
395
+ and the elements themselves just go inert — `draggable={false}`, `readOnly`,
396
+ `disabled` — with no wrapper of their own.
397
+
361
398
  ### Buttons that touch more than one operation
362
399
 
363
400
  Two shapes, and they gate differently. Get this wrong and you either grey out a
@@ -405,6 +442,22 @@ const reason = denied
405
442
  Both shapes hand the method to `accessReason`, so neither needs to know which
406
443
  operation it ended up gating on.
407
444
 
445
+ ### Controls that stage a change
446
+
447
+ A control that **stages** a change — it writes nothing itself; the write is sent
448
+ later, by a Save or an Apply — is gated on the operation that staged change will
449
+ eventually send. Two failure modes, and both are common:
450
+
451
+ - **Gated on nothing.** A Viewer rearranges, fills in and stages happily, and
452
+ meets the refusal at Save — somewhere else on the screen, after the work.
453
+ - **Gated twice in a row.** If the entry point that reaches the staging control
454
+ is already gated on that same operation, the staging control does not repeat
455
+ the gate: the reader has met the sentence once and cannot get here without
456
+ passing it.
457
+
458
+ So: gate the staging control on the staged operation *unless* the upstream
459
+ control is already gated on the same one.
460
+
408
461
  ### Forms with a deferred save
409
462
 
410
463
  A form the reader fills in and *then* saves is the one case where a hint per
package/docs/rules.md CHANGED
@@ -382,6 +382,14 @@ The level MUST come from the generated method itself (`op.level`, on the
382
382
  the App, not a literal, and never a rule of thumb about verbs — `analytics_*_delete`
383
383
  is `write` while `mail_domain_create` is `admin`, and only the catalog knows.
384
384
 
385
+ Where there is no method to read from — a test double, a runtime probe over keys
386
+ it was handed — the App MUST NOT hand-type the level either. `@lessly/sdk-app`
387
+ exports `operations`, the catalog's own level map
388
+ (`Readonly<Record<string, ToolLevel>>`, keyed by operation key), regenerated at
389
+ publish time from the live catalog; read the level from it. A key it does not
390
+ carry answers `undefined`, which is exactly the case `accessReason`'s string form
391
+ covers — no level is invented for it.
392
+
385
393
  The gate belongs to the OPERATION, not to a gesture: where a write is reachable
386
394
  by several paths (drag and arrow keys, click and Enter, button and hotkey) all of
387
395
  them MUST be gated together, or the hole left behind is one only keyboard users
@@ -389,6 +397,20 @@ find. Implicit form submission is such a path: a disabled submit button does not
389
397
  reliably stop Enter inside a field, so the `onSubmit` handler MUST be gated (or
390
398
  the fields made `readOnly`) rather than the button alone.
391
399
 
400
+ A gate that disables a capability across a WHOLE SURFACE — dragging any node on
401
+ a canvas, a bulk action over a list or a grid — MUST attach the reason to the
402
+ control that owns the capability (the toggle, the toolbar, the surface header)
403
+ and MUST NOT attach it to every affected element: thirty identical focusable
404
+ wrappers are thirty extra tab stops and one sentence thirty times, not thirty
405
+ explanations. Keyboard reachability is satisfied on the owning control; the
406
+ elements themselves just go inert.
407
+
408
+ A control that STAGES a change — it writes nothing itself, the write is sent
409
+ later by a Save or an Apply — MUST be gated on the operation the staged change
410
+ will send, UNLESS the upstream control that reaches it is already gated on that
411
+ same operation. Never on nothing (the caller does the work and meets the refusal
412
+ later, somewhere else) and never twice in a row.
413
+
392
414
  A control that performs SEVERAL operations per click is disabled if ANY of them
393
415
  is denied. A DISPATCHER control — one operation per click, selected by a mode or
394
416
  a switch — MUST instead be gated on the operation it is about to call, and name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/sdk-app",
3
- "version": "63.0.0",
3
+ "version": "63.2.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "engines": {