@lessly/sdk-app 62.0.0 → 62.1.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.
@@ -33,10 +33,14 @@ using the *same matcher the gateway uses to decide*. That is the only reason a
33
33
  client-side prediction is worth anything.
34
34
 
35
35
  `can()` is **false whenever the snapshot is not ready**: before `load()`, while
36
- it is in flight, after a failure, and after `invalidate()`. So anything you
37
- render from it must start in the disabled state and enable itself — never the
38
- reverse. A control that starts enabled and greys out a moment later is worse
39
- than one that never greyed out at all.
36
+ it is in flight, after a failure, and after `invalidate()`. So a control wired
37
+ to it starts disabled and enables itself — never the reverse. A control that
38
+ starts enabled and greys out a moment later is worse than one that never greyed
39
+ out at all.
40
+
41
+ `can()` being false is not by itself a reason to *tell the user their role is
42
+ the problem*, though — a failed `/me` is not a denial. See the state table in
43
+ §2 for what each `state` means for a control.
40
44
 
41
45
  You can pass either a tool id or a generated method, which carries its own
42
46
  identity:
@@ -58,17 +62,24 @@ drift from the method it is meant to describe; the method cannot.
58
62
  // src/App.tsx
59
63
  import { useEffect } from 'react';
60
64
  import { createLesslyApp } from '@lessly/sdk-app';
65
+ import { TooltipProvider } from '@lessly/ui';
61
66
 
62
67
  export default function App({ productId }: { productId: string }) {
63
68
  const app = useMemo(() => createLesslyApp({ baseUrl: '/api', productId }), [productId]);
64
69
 
65
70
  useEffect(() => {
66
- // Fire and forget: a failure is reflected in app.access.state, and every consumer of
67
- // can() is already fail-closed.
71
+ // Fire and forget: a failure is reflected in app.access.state, and can() stays false
72
+ // until the snapshot is ready.
68
73
  void app.access.load().catch(() => {});
69
74
  }, [app]);
70
75
 
71
- return <Routes app={app} />;
76
+ // One TooltipProvider per App canvas — see §2. Never one per control, and never
77
+ // borrowed from the shell: your standalone build and your tests render outside it.
78
+ return (
79
+ <TooltipProvider>
80
+ <Routes app={app} />
81
+ </TooltipProvider>
82
+ );
72
83
  }
73
84
  ```
74
85
 
@@ -87,23 +98,174 @@ refetched on a timer.
87
98
  ## 2. `useCan` on primary actions — disable, don't hide
88
99
 
89
100
  ```tsx
90
- import { useCan } from '@lessly/sdk-app/react';
101
+ import { accessReason } from '@lessly/sdk-app';
102
+ import { useAccess, useCan } from '@lessly/sdk-app/react';
103
+ import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@lessly/ui';
91
104
 
92
105
  function AddDomainButton({ app, onClick }: { app: App; onClick: () => void }) {
106
+ const { state } = useAccess(app);
93
107
  const canCreate = useCan(app, app.mail.domain.create);
94
108
 
95
- return (
96
- <button
97
- onClick={onClick}
98
- disabled={!canCreate}
99
- title={canCreate ? undefined : 'Your role cannot add domains. Ask an admin of this product.'}
100
- >
109
+ // 'error' is NOT a denial: nothing is known about the role, so make no prediction.
110
+ const disabled = state === 'ready' ? !canCreate : state !== 'error';
111
+ // The reason names the level AND the operation key, both read off the method —
112
+ // never hand-typed per button, and never the caller's role.
113
+ const reason =
114
+ state === 'ready' && !canCreate
115
+ ? accessReason(app.mail.domain.create)
116
+ : undefined;
117
+
118
+ const button = (
119
+ <Button disabled={disabled} onClick={onClick}>
101
120
  Add domain
102
- </button>
121
+ </Button>
122
+ );
123
+
124
+ if (reason === undefined) return button;
125
+
126
+ return (
127
+ <Tooltip>
128
+ {/* The trigger is the WRAPPER, not the Button: the base buttonVariants set
129
+ disabled:pointer-events-none, so a disabled control fires no hover and a
130
+ tooltip mounted on it never opens. */}
131
+ <TooltipTrigger asChild>
132
+ <span tabIndex={0}>{button}</span>
133
+ </TooltipTrigger>
134
+ <TooltipContent>{reason}</TooltipContent>
135
+ </Tooltip>
103
136
  );
104
137
  }
105
138
  ```
106
139
 
140
+ ### The reason goes on a wrapper, never on `title=`
141
+
142
+ A `title` on a disabled Button is unreachable twice over: `disabled:pointer-events-none`
143
+ swallows the hover the browser needs to show it, and a native tooltip is unreachable
144
+ by keyboard and by touch even on an enabled control. That class sits in the **base**
145
+ `buttonVariants`, not in an icon-only branch — so the wrapper pattern applies to
146
+ *every* Button, not just the icon-only shape. The wrapping trigger carries
147
+ `tabIndex={0}` for the same reason: a keyboard user has to be able to reach the hint.
148
+ This mirrors what `@lessly/ui` documents above `ButtonIconOnlyProps` in `button.tsx`
149
+ (`Button --disabled-with-tooltip` shows both halves).
150
+
151
+ Controls that take no `title` at all — the kit's `Switch`, for one — have no other
152
+ option: the wrapper is the only place the reason can live.
153
+
154
+ **The trigger must sit outside every `pointer-events-none`, including your own.**
155
+ The kit's `disabled:pointer-events-none` is the common case, but the mechanism is
156
+ not the kit's: a list row, a chip or an overlay in your own App that you set
157
+ `pointer-events-none` on swallows hover identically, and a trigger nested inside
158
+ it is just as silent — in your code this time. Walk outwards from the control to
159
+ the first ancestor that still receives pointer events, and put the trigger there.
160
+
161
+ ### One reason string, built from the method
162
+
163
+ Every App says the same sentence, and both of its values come off the generated
164
+ method:
165
+
166
+ ```tsx
167
+ import { accessReason } from '@lessly/sdk-app';
168
+
169
+ accessReason(app.mail.domain.create);
170
+ // "Requires level:admin (mail_domain_create). Ask an admin of this product."
171
+ ```
172
+
173
+ Use it in every example below; nothing in an App should assemble this text by
174
+ hand.
175
+
176
+ It takes **one** operation. Choosing which operation to explain — the first one
177
+ `can()` denied, out of the several a button performs — is the caller's job, and
178
+ it stays the caller's job: the helper accepts no array, because the rule for
179
+ picking is not the same in the two shapes below and a helper that guessed would
180
+ be wrong in one of them.
181
+
182
+ Its signature is `Operation | string`, and the two arms are not equals. Passing a
183
+ method is the norm. The string arm is a **degradation**, there for the controls
184
+ that have no generated method to point at — a runtime probe, an operation no
185
+ method covers — and it says less because less is known:
186
+
187
+ ```tsx
188
+ accessReason('some_runtime_probe');
189
+ // "Requires access to some_runtime_probe. Ask an admin of this product."
190
+ ```
191
+
192
+ It drops the level rather than inventing one: with only a key there is nothing to
193
+ read a level from, and `level:undefined` in front of a user is worse than saying
194
+ less. It is not permission to pass strings where a method exists — the accessor
195
+ path is a lossy view of the key, so a hand-written string can drift from the
196
+ operation it names, and only the method's arm gives an admin both halves of what
197
+ they grant on. An App whose every gate receives an `Operation` never writes the
198
+ string form at all; that is the normal shape of an App, not a gap in it.
199
+
200
+ `can()` is cautious with a bare string in the same way. It resolves the level
201
+ from the catalog when the key is one the SDK knows, but a key with **no** catalog
202
+ entry can only be matched by key pattern — no level grant reaches it, which is
203
+ exactly what the gateway does with an uncatalogued key.
204
+
205
+ > **On an older SDK.** `accessReason` ships from `@lessly/sdk-app`. On a version
206
+ > before it landed, define it locally with exactly this signature and body, then
207
+ > replace it with the import when you upgrade:
208
+ >
209
+ > ```ts
210
+ > import type { Operation } from '@lessly/sdk-app';
211
+ > export const accessReason = (op: Operation | string): string =>
212
+ > typeof op === 'string'
213
+ > ? `Requires access to ${op}. Ask an admin of this product.`
214
+ > : `Requires level:${op.level} (${op.operationKey}). Ask an admin of this product.`;
215
+ > ```
216
+
217
+ **Why both values.** The reason says what an admin has to *grant*, not what the
218
+ caller happens to be — a role name gives them nothing to act on, and "your role
219
+ cannot do this" sends the user into a conversation with no next step. But a level
220
+ alone is not enough either: seventeen controls on a screen can all read
221
+ `level:write` and be seventeen different grants. An admin grants both **by level
222
+ and by exact operation key**, so the user has to be able to pass on both. The key
223
+ is what makes the sentence actionable; the level is what makes it
224
+ comprehensible.
225
+
226
+ **Both come only from the method.** `op.level` and `op.operationKey` — every
227
+ generated method satisfies `interface Operation { operationKey, level }`. No
228
+ lookup table in your App, no literal in the JSX, and above all no rule of thumb
229
+ about which verbs are which level. There is no such rule: `analytics_dashboard_delete`
230
+ and `analytics_insight_delete` are `write`, `mail_domain_create` is `admin`, and
231
+ the whole `users` namespace has exactly six `admin` operations. Only the catalog
232
+ knows, the method carries what the catalog said, and a reason built from the
233
+ method cannot drift the day an operation is re-levelled. The accessor path is a
234
+ lossy view of the key, so never rebuild `operationKey` from it either — read it
235
+ off the method.
236
+
237
+ ### Your App mounts its own `TooltipProvider`
238
+
239
+ Mount **one `TooltipProvider` per App canvas** — at the root of your remote, as in
240
+ §1 — and never one per control. Do not rely on the shell for it: `Tooltip.Root`
241
+ *throws* without a provider above it, so a remote that inherits one only by luck
242
+ does not lose its hint, it crashes — and your standalone build, your dev entry and
243
+ your tests all render outside the shell entirely.
244
+
245
+ ### What each access state means for a control
246
+
247
+ | `state` | Control | Reason shown |
248
+ | --- | --- | --- |
249
+ | `idle` / `loading` | disabled | none |
250
+ | `ready` | disabled iff `can()` is false | only when `can()` is false |
251
+ | `error` | **enabled** | none — no prediction |
252
+
253
+ `idle`/`loading` start disabled and enable themselves — a control that starts
254
+ enabled and greys out a moment later is worse than one that never greyed out.
255
+ But it claims **no reason** while the snapshot is still in flight: nothing is
256
+ known yet about which level is missing, so there is nothing to say.
257
+
258
+ `error` (the `/me` call failed: a 500, a dropped connection, an unparsable product
259
+ id) is the case worth getting right: **make no prediction**. Leave the control
260
+ enabled, name no missing level, let the gateway decide, and render the refusal
261
+ neutrally through `isAccessDenied` — disabling on error makes your client the
262
+ authority and tells the user a level is missing when nothing at all is known
263
+ about their access.
264
+
265
+ `can()` itself is unchanged by any of this: it is `false` unless the state is
266
+ `ready`. The `error` branch is a decision your *control* makes, not something
267
+ `can()` reports.
268
+
107
269
  **Disable, with a hint. Do not hide.** A user who cannot see the button cannot
108
270
  tell their admin which permission they are missing — they file a "the app is
109
271
  broken" ticket instead, and the admin who could have fixed it in ten seconds
@@ -123,6 +285,179 @@ if (state === 'error') return <p>Could not load your permissions. <button onClic
123
285
  React is an **optional peer** of this package: `@lessly/sdk-app/react` is only
124
286
  loaded if you import it, and installing the SDK does not pull React in.
125
287
 
288
+ ### Actions without a button
289
+
290
+ A write does not stop being a write because nothing on screen says "Save". Drag to
291
+ reorder, an inline-editable cell, a field that autosaves on blur — each is a
292
+ gated operation, and each is gated the **same way** a button is: put the control
293
+ into its inactive state the native way for that control, then hang the reason on
294
+ a keyboard-reachable `Tooltip` wrapper exactly as above.
295
+
296
+ | Control | Inactive state |
297
+ | --- | --- |
298
+ | Drag handle / sortable row | `draggable={false}` (and drop the drag listeners) |
299
+ | Inline-editable field | `readOnly` |
300
+ | Autosave-on-blur input | `readOnly` |
301
+ | Switch / checkbox | `disabled` |
302
+
303
+ ```tsx
304
+ function ReorderableRow({ app, row }: { app: App; row: Row }) {
305
+ const { state } = useAccess(app);
306
+ const canReorder = useCan(app, app.mail.domain.update);
307
+ const denied = state === 'ready' && !canReorder;
308
+
309
+ const item = (
310
+ <li draggable={!denied} onDragStart={denied ? undefined : startDrag}>
311
+ {row.name}
312
+ </li>
313
+ );
314
+
315
+ if (!denied) return item;
316
+
317
+ return (
318
+ <Tooltip>
319
+ <TooltipTrigger asChild>
320
+ <span tabIndex={0}>{item}</span>
321
+ </TooltipTrigger>
322
+ <TooltipContent>
323
+ {accessReason(app.mail.domain.update)}
324
+ </TooltipContent>
325
+ </Tooltip>
326
+ );
327
+ }
328
+ ```
329
+
330
+ **Gate the operation, not the gesture.** A write is often reachable by more than
331
+ one path — a row that reorders by mouse drag *and* by arrow keys, a control that
332
+ fires on click *and* on Enter, an action with a button *and* a hotkey. The gate
333
+ has to silence all of them at once. Gating the drag and leaving the arrow keys
334
+ live is not a cosmetic miss: it is a hole, and it is a hole that only keyboard
335
+ users fall into. Derive the flag once per operation and let every path read it,
336
+ rather than attaching a check to each handler. Acceptance under a viewer checks
337
+ the alternative path too, not just the obvious one.
338
+
339
+ **Enter inside a form field is one of those paths.** A form with a single text
340
+ input submits on Enter whether or not you wired anything to it — that is implicit
341
+ submission, and a disabled submit button does not reliably stop it. Gate the
342
+ `onSubmit` handler itself, or make the fields `readOnly`; a greyed-out button
343
+ beside a live Enter key is the same keyboard-only hole in a different costume.
344
+
345
+ ```tsx
346
+ <form
347
+ onSubmit={(event) => {
348
+ event.preventDefault();
349
+ if (denied) return; // the button being disabled is NOT enough
350
+ save();
351
+ }}
352
+ >
353
+ ```
354
+
355
+ "Don't gate it, let the gateway refuse" is allowed for exactly one case: the
356
+ element physically has no wrapper that can be made focusable. That is rare, and
357
+ it is not a judgement call you make silently — write it into your App's spec as a
358
+ named exception with the reason. An ungated write that nobody wrote down is
359
+ indistinguishable from one nobody thought about.
360
+
361
+ ### Buttons that touch more than one operation
362
+
363
+ Two shapes, and they gate differently. Get this wrong and you either grey out a
364
+ working button or leave a broken one live.
365
+
366
+ **A button that performs several operations per click** — "Deploy" that creates,
367
+ uploads and promotes — is disabled if **any** of them is denied, and names the
368
+ level and key of the one that blocked it, not those of the headline operation. Call
369
+ `useAccess` once and use the synchronous `access.can()` for each; `useCan` is a
370
+ hook and cannot be called in a loop.
371
+
372
+ ```tsx
373
+ const OPS = [app.mail.domain.create, app.mail.domain.verify] as const;
374
+
375
+ function AddAndVerifyButton({ app, onClick }: { app: App; onClick: () => void }) {
376
+ const { state, access } = useAccess(app);
377
+ const blocking = state === 'ready' ? OPS.find((op) => !access.can(op)) : undefined;
378
+
379
+ const disabled = state === 'ready' ? blocking !== undefined : state !== 'error';
380
+ const reason = blocking
381
+ ? accessReason(blocking)
382
+ : undefined;
383
+
384
+ // …same wrapper as above when `reason` is set.
385
+ }
386
+ ```
387
+
388
+ **A dispatcher button** — one operation per click, picked by a mode or a switch
389
+ beside it: *Import* over `users | waitlist`, *Save* that creates or updates
390
+ depending on whether the record exists — is gated by the operation it is
391
+ **actually about to call**, and its reason names *that* operation's level and key. Not
392
+ the union: a caller who may import users but not the waitlist keeps a working
393
+ Import button, and it greys out when they flip the switch.
394
+
395
+ ```tsx
396
+ const op = mode === 'users' ? app.users.users.import : app.users.waitlist.import;
397
+
398
+ const { state, access } = useAccess(app);
399
+ const denied = state === 'ready' && !access.can(op);
400
+ const reason = denied
401
+ ? accessReason(op)
402
+ : undefined;
403
+ ```
404
+
405
+ Both shapes hand the method to `accessReason`, so neither needs to know which
406
+ operation it ended up gating on.
407
+
408
+ ### Forms with a deferred save
409
+
410
+ A form the reader fills in and *then* saves is the one case where a hint per
411
+ control is the wrong shape. Fifteen disabled fields, each explaining itself on
412
+ hover, tell the same sentence fifteen times — and the reader who is going to be
413
+ refused learns it only after typing.
414
+
415
+ Prefer **one line in the card's footer**, stating the constraint before anyone
416
+ starts editing:
417
+
418
+ ```tsx
419
+ import { Card, CardNote } from '@lessly/ui';
420
+
421
+ <Card title="Domain settings">
422
+ {fields}
423
+ {denied && (
424
+ <CardNote>{accessReason(app.mail.domain.update)}</CardNote>
425
+ )}
426
+ </Card>
427
+ ```
428
+
429
+ `CardNote` is exactly this line — a lock and one sentence at the card's foot,
430
+ with no tone and no variant, precisely so a frozen card does not grow a box it
431
+ did not have when it was live. It speaks for the card, not for one control on it;
432
+ a constraint that covers a single row still belongs on that row.
433
+
434
+ The fields themselves stay `readOnly` and the Save button stays disabled — the
435
+ footer note replaces the *per-control tooltips*, not the gate.
436
+
437
+ ### Verify the hint from the keyboard
438
+
439
+ The whole point of the wrapper is that the reason survives the disabled state, and
440
+ the only proof of that is **reaching it with the keyboard**. Tab to the wrapper;
441
+ the tooltip opens.
442
+
443
+ ```tsx
444
+ it('explains the missing level to a keyboard user', async () => {
445
+ const user = userEvent.setup();
446
+ render(<AddDomainButton app={viewerApp} onClick={() => {}} />);
447
+
448
+ await user.tab(); // focus lands on the wrapper
449
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
450
+ 'Requires level:admin (mail_domain_create). Ask an admin of this product.'
451
+ );
452
+ });
453
+ ```
454
+
455
+ Assert the **rendered tooltip**, not an attribute. A test that only checks that
456
+ some `title`, `aria-describedby` or `data-*` is present passes cheerfully against
457
+ a hint no user can ever see — which is exactly the bug this pattern exists to fix.
458
+ Manual acceptance is the same check by hand: Tab to the control, the reason
459
+ appears, no mouse involved.
460
+
126
461
  ---
127
462
 
128
463
  ## 3. `isAccessDenied` in the shared query state — a neutral empty state
@@ -163,6 +498,94 @@ one: it sends the user to an admin who can do nothing about it.
163
498
  frequent and not the user's mistake. Painting it as a crash teaches people to
164
499
  ignore the colour you need for real failures.
165
500
 
501
+
502
+ ### A refusal inside the kit's `ConfirmDialog`
503
+
504
+ `ConfirmDialog` takes `onError?: (error: unknown) => string` — a string, and the
505
+ kit picks the tone from it. So a 403 raised by the confirmed action cannot yet be
506
+ rendered *neutrally* inside the dialog the way it is on a screen: you own the
507
+ words, the kit owns the colour.
508
+
509
+ Do the half you own. Route the error through the same classifier your shared
510
+ query state uses, so the sentence a denied caller reads is the same neutral one
511
+ everywhere:
512
+
513
+ ```tsx
514
+ <ConfirmDialog
515
+ title="Delete domain"
516
+ onConfirm={() => app.mail.domain.delete({ id })}
517
+ onError={(error) =>
518
+ isAccessDenied(error)
519
+ ? "You don't have access to delete domains. Ask an admin of this product."
520
+ : 'Could not delete the domain. Try again.'
521
+ }
522
+ />
523
+ ```
524
+
525
+ **Do not move the confirm flow into your App to win the colour.** Reimplementing
526
+ the dialog costs you the focus trap, the busy state, the confirm phrase and the
527
+ step-up — all to repaint one line of text.
528
+
529
+ This is known kit debt, tracked as platform **#2915** (`onError` returning
530
+ `{ text, tone }` so the kit can render a denial neutrally). It is tolerable in
531
+ the meantime because a denied caller rarely reaches the dialog at all: the
532
+ control that opens it is disabled under exactly the roles that would be refused.
533
+ A red refusal inside the dialog is only reachable in the snapshot's `error`
534
+ state, where — by the rule above — the App deliberately makes no prediction and
535
+ lets the gateway answer.
536
+
537
+ ---
538
+
539
+ ## 4. Access fixtures in e2e
540
+
541
+ Do not point an e2e run at a real product to get a real role. Mock
542
+ `GET /governance/api/v1/products/:productId/me` per scenario instead: it is one
543
+ flat body, and the role you need is whichever one the scenario is about.
544
+
545
+ Never hard-code a real product uuid in the repo, and never create a long-lived
546
+ "smoke test" product to own these roles — both rot into a shared fixture nobody
547
+ dares change, and both make the suite depend on grants that live outside it.
548
+
549
+ ```ts
550
+ // An owner: everything, minus the deny patterns seeded on every product.
551
+ const OWNER = {
552
+ role: 'owner',
553
+ allow: ['*'],
554
+ deny: [],
555
+ };
556
+
557
+ // A member: read and write, no governance and no billing.
558
+ const MEMBER = {
559
+ role: 'member',
560
+ allow: ['level:read', 'level:write'],
561
+ deny: [
562
+ '*_roles_create',
563
+ '*_roles_update',
564
+ '*_roles_delete',
565
+ '*_members_assign-role',
566
+ '*_billing_*',
567
+ ],
568
+ };
569
+
570
+ // A viewer: read only — the scenario that greys out every primary action.
571
+ const VIEWER = {
572
+ role: 'viewer',
573
+ allow: ['level:read'],
574
+ deny: [
575
+ '*_roles_create',
576
+ '*_roles_update',
577
+ '*_roles_delete',
578
+ '*_members_assign-role',
579
+ '*_billing_*',
580
+ ],
581
+ };
582
+ ```
583
+
584
+ Two more responses are worth a scenario each, because they are the two the UI
585
+ gets wrong: a **failing** `/me` (500 or a dropped connection — every control
586
+ stays enabled and claims nothing about the role) and a **403** from the action
587
+ itself with a `role_denied` body (the neutral empty state, not the red one).
588
+
166
589
  ---
167
590
 
168
591
  ## What not to do
@@ -175,3 +598,21 @@ ignore the colour you need for real failures.
175
598
  | Hide controls the caller cannot use | Makes the missing permission unreportable. Disable with a reason instead. |
176
599
  | Rebuild a tool id from the accessor path | The path is a lossy view of the id. Pass the method: `app.mail.domain.create`. |
177
600
  | Poll `load()` on a timer | The snapshot changes when a role changes; call `invalidate()` then, and only then. |
601
+ | Hand-write the reason text | It goes stale when the catalog re-levels the tool. Build it with `accessReason(op)`. |
602
+ | Name the caller's role in the reason | A role is not something an admin grants. Name the level and the operation key. |
603
+ | Assert only that a hint attribute exists | Green against a tooltip no user can reach. Tab to it and assert the rendered `role="tooltip"`. |
604
+ | Rely on a disabled submit button to stop Enter | Implicit submission fires anyway. Gate `onSubmit`, or make the fields `readOnly`. |
605
+ | Gate a dispatcher button on the union of its modes | It calls one operation per click. Gate the one it is about to call. |
606
+ | Repeat the same tooltip on every field of a deferred-save form | One `CardNote` in the footer, read before editing rather than after. |
607
+ | Gate the drag but not the arrow keys | Same operation, two paths. The hole is keyboard-only, which is the worst place for one. |
608
+ | Nest the Tooltip trigger inside your own `pointer-events-none` | Same silence as the kit's disabled button, in your code. Put the trigger outside it. |
609
+ | Infer a level from the verb ("delete means admin") | There is no such rule: analytics deletes are `write`. Read `op.level`. |
610
+ | Say only the level, with no operation key | Seventeen controls can share one level. The key is what an admin grants on. |
611
+ | Render `level:undefined` for a key with no method | Use the string arm of `accessReason`: name the key, drop the level. |
612
+ | Pass a string key where the method exists | The string arm is a degradation for methodless operations, not an equal input. |
613
+ | Hand `accessReason` a list of operations | It explains one. Pick the denied one first; the rule differs per button shape. |
614
+ | Reimplement `ConfirmDialog` to repaint a denial | Costs the focus trap, busy state, confirm phrase and step-up for one line of text. Kit debt #2915. |
615
+ | Leave a drag handle or inline edit ungated because it has no button | It is the same write. Set the native inactive state and wrap it. Skipping the gate is a spec'd exception, not a default. |
616
+ | Name the headline operation on a multi-operation button | Name the level and key of the operation that actually blocked it. |
617
+ | Put the reason in a native `title=` on the control | `disabled:pointer-events-none` swallows the hover, and a native tooltip is unreachable by keyboard and by touch. Wrap the control in a `TooltipTrigger`. |
618
+ | Disable a control because `state === 'error'` | Nothing is known about the role. Stay enabled, say nothing about the role, let the gateway answer. |
package/docs/rules.md CHANGED
@@ -338,6 +338,91 @@ An App SHOULD reflect the caller's access in its UI: **disable, with a reason**
338
338
  (not hide) a primary action the caller cannot perform, using `useCan` from
339
339
  `@lessly/sdk-app/react` or `app.access.can()` directly, and render a 403 that
340
340
  `isAccessDenied()` recognises as a **neutral empty state** rather than an error.
341
+ That reason MUST live on a `Tooltip` wrapper around the disabled control, never
342
+ on a native `title=` of the control itself: the base `buttonVariants` carry
343
+ `disabled:pointer-events-none`, so a disabled control fires no hover for the
344
+ browser to render a `title` from, and a native tooltip is unreachable by
345
+ keyboard and by touch in any case. An App that draws such a tooltip MUST mount
346
+ its own `TooltipProvider` once at the root of its remote — `Tooltip.Root` throws
347
+ without one, and the standalone build, the dev entry and the tests all render
348
+ outside the shell.
349
+
350
+ That reason MUST be exactly one sentence, the same in every App:
351
+
352
+ > `Requires level:<op.level> (<op.operationKey>). Ask an admin of this product.`
353
+
354
+ — e.g. `Requires level:admin (mail_domain_create). Ask an admin of this product.`
355
+ Both values MUST be read from the generated method, never written as literals.
356
+ `@lessly/sdk-app` exports `accessReason(op)`, which is that sentence; an App
357
+ SHOULD use it rather than assembling the text itself.
358
+
359
+ Where a control has only an operation key and no generated method to point at,
360
+ the App MUST NOT guess the level or render `level:undefined`; it says instead:
361
+
362
+ > `Requires access to <operationKey>. Ask an admin of this product.`
363
+
364
+ That string form is a **degradation** for operations no generated method covers,
365
+ not licence to pass a key where a method exists — the accessor path is a lossy
366
+ view of the key. An App whose every gate holds an `Operation` never uses it, and
367
+ that is the norm rather than an omission. `accessReason` explains ONE operation;
368
+ choosing which one to explain among a button's several is the caller's, not the
369
+ helper's.
370
+ It names what an admin can grant, not the caller's role: a role name gives them
371
+ nothing to act on. It names the operation key as well as the level because a
372
+ level alone does not identify the grant — seventeen controls on one screen can
373
+ all require `level:write` — and an admin grants both by level and by exact key,
374
+ so the user has to be able to pass on both. Building it from the method also
375
+ keeps it from drifting when the catalog re-levels a tool. Whatever renders it MUST be verified reachable **from the keyboard** —
376
+ Tab to the wrapper, the tooltip opens (in tests: `userEvent.tab()` then
377
+ `findByRole('tooltip')`). Asserting only that a hint attribute is present does
378
+ not satisfy this: that assertion is green against a hint no user can reach.
379
+
380
+ The level MUST come from the generated method itself (`op.level`, on the
381
+ `Operation` the method satisfies) and from nowhere else: not a lookup table in
382
+ the App, not a literal, and never a rule of thumb about verbs — `analytics_*_delete`
383
+ is `write` while `mail_domain_create` is `admin`, and only the catalog knows.
384
+
385
+ The gate belongs to the OPERATION, not to a gesture: where a write is reachable
386
+ by several paths (drag and arrow keys, click and Enter, button and hotkey) all of
387
+ them MUST be gated together, or the hole left behind is one only keyboard users
388
+ find. Implicit form submission is such a path: a disabled submit button does not
389
+ reliably stop Enter inside a field, so the `onSubmit` handler MUST be gated (or
390
+ the fields made `readOnly`) rather than the button alone.
391
+
392
+ A control that performs SEVERAL operations per click is disabled if ANY of them
393
+ is denied. A DISPATCHER control — one operation per click, selected by a mode or
394
+ a switch — MUST instead be gated on the operation it is about to call, and name
395
+ that operation's level and key; gating it on the union greys out a button the caller can
396
+ in fact use.
397
+
398
+ An App SHOULD state the constraint once in a card footer (`CardNote`) rather than
399
+ on every control of a form whose save is deferred: the reader meets the sentence
400
+ before editing rather than after, and one line replaces fifteen identical
401
+ tooltips. The fields stay inactive and the save stays disabled either way — the
402
+ note replaces the per-control hints, not the gate. And the `Tooltip` trigger MUST sit outside every element carrying
403
+ `pointer-events-none` — the App's own lists, chips and overlays as much as the
404
+ kit's disabled buttons — since the hint is swallowed by the same mechanics either
405
+ way.
406
+
407
+ This applies to every gated write, not only the ones with a button. A drag
408
+ handle, an inline-editable cell and a field that autosaves on blur are gated the
409
+ same way: the native inactive state for that control (`draggable={false}`,
410
+ `readOnly`, `disabled`) plus the reason on a keyboard-reachable `Tooltip`
411
+ wrapper. Leaving a write ungated for the gateway to refuse is permitted ONLY
412
+ where the element has no wrapper that can be made focusable, and that exception
413
+ MUST be written into the App's own spec with its reason. A button that performs
414
+ several operations is disabled if ANY of them is denied, and its reason names
415
+ the level and key of the operation that blocked it.
416
+
417
+ The reason is claimed only when the App actually knows it. With
418
+ `access.state === 'error'` the `/me` call failed and **nothing** is known about
419
+ the caller's role: the App MUST make no prediction — leave the control enabled,
420
+ name no missing operation, and let the gateway answer, rendering any refusal through
421
+ `isAccessDenied`. Disabling on error makes the client the authority and tells
422
+ the user their role forbids something no one has established. (`can()` itself is
423
+ unaffected: it stays `false` unless the state is `ready`.) While the state is
424
+ `idle` or `loading` the control SHOULD start disabled and enable itself, but
425
+ SHOULD NOT name a missing level or operation it does not yet know.
341
426
 
342
427
  An App MUST NOT treat that prediction as enforcement. `can()` answers from a
343
428
  snapshot fetched once from the `organization_product_me` tool; the gateway is
@@ -363,4 +448,11 @@ Pass the generated method rather than a hand-written tool id
363
448
  (`app.access.can(app.mail.domain.create)`): the accessor path is a lossy view
364
449
  of the tool id, so a literal string can silently drift from the call it gates.
365
450
 
451
+ One gap is known and accepted: `@lessly/ui`'s `ConfirmDialog` takes an `onError`
452
+ that returns a string, so the kit picks the tone and a refusal raised inside the
453
+ dialog cannot be painted neutrally yet. An App SHOULD still route that error
454
+ through `isAccessDenied()` for the *words*, and MUST NOT reimplement the confirm
455
+ flow to win the colour — it would forfeit the focus trap, busy state, confirm
456
+ phrase and step-up. Tracked as platform #2915.
457
+
366
458
  See `recipes/access.md` for the load-once pattern and the empty-state shape.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/sdk-app",
3
- "version": "62.0.0",
3
+ "version": "62.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "engines": {