@startsimpli/ui 0.4.122 → 0.4.124

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.122",
3
+ "version": "0.4.124",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,9 +47,9 @@
47
47
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
- "@startsimpli/api": "^0.5.58",
51
- "@startsimpli/auth": "^0.4.35",
52
- "@startsimpli/i18n": "^0.1.3"
50
+ "@startsimpli/api": "^0.5.59",
51
+ "@startsimpli/i18n": "^0.1.3",
52
+ "@startsimpli/auth": "^0.4.35"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -123,8 +123,8 @@
123
123
  "tailwindcss": "^3.4.19",
124
124
  "tsup": "^8.5.1",
125
125
  "typescript": "^6.0.3",
126
- "@startsimpli/i18n": "0.1.3",
127
- "@startsimpli/realtime": "0.0.2"
126
+ "@startsimpli/realtime": "0.0.2",
127
+ "@startsimpli/i18n": "0.1.3"
128
128
  },
129
129
  "keywords": [
130
130
  "ui",
@@ -0,0 +1,61 @@
1
+ import * as React from 'react'
2
+ import { Absence, type AbsenceTier } from './Absence'
3
+ import { scopeAbsence, scopeNotice, type ScopeAbsenceInput, type ScopeAbsenceState } from './scope-access'
4
+
5
+ export interface ScopeAbsenceProps extends ScopeAbsenceInput {
6
+ /** How much room to give it. A whole table's worth of nothing is a `page`. */
7
+ tier?: AbsenceTier
8
+ className?: string
9
+ /** Rendered when the view is NOT scope-blocked — the collection's own empty state. */
10
+ fallback?: React.ReactNode
11
+ }
12
+
13
+ /**
14
+ * The Absence a scope-gated reader gets instead of "No results found."
15
+ *
16
+ * Decides with {@link scopeAbsence} and renders with {@link Absence}, so the
17
+ * vocabulary is the one every other missing thing already speaks. When the view
18
+ * is not scope-blocked it renders `fallback` — usually nothing, which lets the
19
+ * table draw its ordinary empty row.
20
+ */
21
+ export function ScopeAbsence({
22
+ tier = 'page',
23
+ className,
24
+ fallback = null,
25
+ ...input
26
+ }: ScopeAbsenceProps) {
27
+ const state = scopeAbsence(input)
28
+ if (!state) return <>{fallback}</>
29
+ return (
30
+ <div data-scope-absence={state.reason} className={className}>
31
+ <Absence
32
+ tier={tier}
33
+ title={state.title}
34
+ description={state.description}
35
+ why={state.why}
36
+ />
37
+ </div>
38
+ )
39
+ }
40
+
41
+ /**
42
+ * The standing one-line "your counts cover one scope" disclosure, on the same
43
+ * Absence vocabulary at its quietest tier. Renders nothing for an exempt or
44
+ * ungated reader, so it is safe to leave in a shared header.
45
+ */
46
+ export function ScopeNotice({
47
+ access,
48
+ typeKey,
49
+ typeLabel,
50
+ className,
51
+ }: Pick<ScopeAbsenceProps, 'access' | 'typeKey' | 'typeLabel' | 'className'>) {
52
+ const note = scopeNotice({ access, typeKey, typeLabel })
53
+ if (!note) return null
54
+ return (
55
+ <div data-scope-notice="narrowed" className={className}>
56
+ <Absence tier="note" title="Scoped view" description={note} />
57
+ </div>
58
+ )
59
+ }
60
+
61
+ export type { ScopeAbsenceState }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * bd startsim-44bar — a scope-gated reader is told WHY a list is empty.
3
+ *
4
+ * The wire fixtures here are the two spellings the same report really arrives
5
+ * in: snake_case straight off Django (`apps/api/pagination.py`, and every
6
+ * tenant-starter test), camelCase after the shared @startsimpli/api client has
7
+ * transformed the response. Both are exercised because a reader that handles
8
+ * only one is a reader that works in unit tests and not in the browser, or the
9
+ * reverse. The values are the live marketing-agents report, copied from
10
+ * `GET /api/v1/entities/?type=draft` on 2026-09-17.
11
+ */
12
+ import * as React from 'react'
13
+ import { render, screen } from '@testing-library/react'
14
+ import { readScopeAccess, scopeAbsence, scopeNotice } from '../scope-access'
15
+ import { ScopeAbsence, ScopeNotice } from '../ScopeAbsence'
16
+
17
+ const WIRE_SNAKE = {
18
+ count: 0,
19
+ results: [],
20
+ scope_access: {
21
+ scoped_types: ['draft', 'scope', 'topic'],
22
+ exempt: false,
23
+ granted_paths: ['/ogmc-agent-test'],
24
+ },
25
+ }
26
+
27
+ const WIRE_CAMEL = {
28
+ count: 0,
29
+ results: [],
30
+ scopeAccess: {
31
+ scopedTypes: ['draft', 'scope', 'topic'],
32
+ exempt: false,
33
+ grantedPaths: ['/ogmc-agent-test'],
34
+ },
35
+ }
36
+
37
+ const NO_GRANT_DETAIL =
38
+ 'records of draft, scope, topic are scoped, and this account holds no scope ' +
39
+ 'grant, so it reads none of them. An admin grants a scope by sharing that ' +
40
+ 'scope’s own record (POST /api/v1/grants/ naming the scope row and this user).'
41
+
42
+ const WIRE_NO_GRANT = {
43
+ count: 0,
44
+ results: [],
45
+ scope_access: {
46
+ scoped_types: ['draft', 'scope', 'topic'],
47
+ exempt: false,
48
+ granted_paths: [],
49
+ detail: NO_GRANT_DETAIL,
50
+ },
51
+ }
52
+
53
+ describe('readScopeAccess', () => {
54
+ it('reads the report off a list envelope in EITHER spelling', () => {
55
+ for (const wire of [WIRE_SNAKE, WIRE_CAMEL]) {
56
+ expect(readScopeAccess(wire)).toEqual({
57
+ scopedTypes: ['draft', 'scope', 'topic'],
58
+ exempt: false,
59
+ grantedPaths: ['/ogmc-agent-test'],
60
+ })
61
+ }
62
+ })
63
+
64
+ it('reads a bare report, as /whoami/ nests it and as a caller may pass it', () => {
65
+ expect(readScopeAccess(WIRE_SNAKE.scope_access)?.grantedPaths).toEqual(['/ogmc-agent-test'])
66
+ expect(
67
+ readScopeAccess({ sub: 'u1', role: 'admin', scope_access: { scoped_types: ['draft'], exempt: true, granted_paths: [] } }),
68
+ ).toEqual({ scopedTypes: ['draft'], exempt: true, grantedPaths: [] })
69
+ })
70
+
71
+ it('carries the backend’s own detail sentence through', () => {
72
+ expect(readScopeAccess(WIRE_NO_GRANT)?.detail).toBe(NO_GRANT_DETAIL)
73
+ })
74
+
75
+ it('is null for an envelope that carries no report — every tenant today', () => {
76
+ expect(readScopeAccess({ count: 0, next: null, previous: null, results: [] })).toBeNull()
77
+ expect(readScopeAccess(null)).toBeNull()
78
+ expect(readScopeAccess(undefined)).toBeNull()
79
+ expect(readScopeAccess('nope')).toBeNull()
80
+ // A report with no gated types is not one.
81
+ expect(readScopeAccess({ scope_access: { scoped_types: [], exempt: false, granted_paths: [] } })).toBeNull()
82
+ })
83
+ })
84
+
85
+ describe('scopeAbsence', () => {
86
+ const held = readScopeAccess(WIRE_SNAKE)
87
+ const none = readScopeAccess(WIRE_NO_GRANT)
88
+
89
+ it('names the missing GRANT when the account holds no scope at all', () => {
90
+ const state = scopeAbsence({ access: none, typeKey: 'draft', typeLabel: 'Draft', readableCount: 0 })
91
+ expect(state?.reason).toBe('no-grant')
92
+ // The remedy is the backend's own sentence, not a second copy of it here.
93
+ expect(state?.why).toBe(NO_GRANT_DETAIL)
94
+ expect(state?.description).toContain('missing grant')
95
+ })
96
+
97
+ it('names the scopes held — and never the ones it cannot see — otherwise', () => {
98
+ const state = scopeAbsence({ access: held, typeKey: 'topic', typeLabel: 'Topic', readableCount: 0 })
99
+ expect(state?.reason).toBe('other-scope')
100
+ expect(state?.description).toContain('/ogmc-agent-test')
101
+ // It must not claim to know where the rows it cannot read actually are.
102
+ expect(`${state?.title} ${state?.description} ${state?.why}`).not.toContain('/ogmc,')
103
+ })
104
+
105
+ it('says NOTHING about a type the axis does not gate', () => {
106
+ // The report rides on every list a gated caller makes, news_item included.
107
+ expect(scopeAbsence({ access: held, typeKey: 'news_item', readableCount: 0 })).toBeNull()
108
+ })
109
+
110
+ it('says nothing when the reader CAN reach rows — a filter emptying the view is not a scope gap', () => {
111
+ // The live marketing-agents sandbox: four drafts readable, a "last 7 days"
112
+ // facet matching none of them. That empty table is the filter's doing and
113
+ // must stay the ordinary empty state.
114
+ expect(scopeAbsence({ access: held, typeKey: 'draft', readableCount: 4 })).toBeNull()
115
+ expect(scopeAbsence({ access: held, typeKey: 'draft', readableCount: 0, loading: true })).toBeNull()
116
+ expect(
117
+ scopeAbsence({
118
+ access: readScopeAccess({ scope_access: { scoped_types: ['draft'], exempt: true, granted_paths: [] } }),
119
+ typeKey: 'draft',
120
+ readableCount: 0,
121
+ }),
122
+ ).toBeNull()
123
+ })
124
+
125
+ it('says nothing at all when no report came back', () => {
126
+ expect(scopeAbsence({ access: null, typeKey: 'draft', readableCount: 0 })).toBeNull()
127
+ })
128
+
129
+ it('is pending-safe — it never accuses a read that has not answered yet', () => {
130
+ expect(
131
+ scopeAbsence({ access: undefined, typeKey: 'draft', readableCount: 0, loading: true }),
132
+ ).toBeNull()
133
+ })
134
+
135
+ it('A FAILED probe is not a scope gap', () => {
136
+ // The shape a 500, a 401-refresh race or a network blip leaves behind: the
137
+ // query has SETTLED (loading false), so there is no pending flag to hide
138
+ // behind, and `readableCount` has coerced to 0 on the caller's `?? 0`. The
139
+ // report is what carries the claim, and there is none — so this says
140
+ // nothing and the collection keeps its ordinary empty state. Asserting a
141
+ // permissions failure we did not observe would be startsim-4ipm's fallback
142
+ // bug pointed the other way: a confident verdict from a read that failed.
143
+ expect(
144
+ scopeAbsence({ access: undefined, typeKey: 'draft', readableCount: 0, loading: false }),
145
+ ).toBeNull()
146
+ expect(scopeNotice({ access: undefined, typeKey: 'draft' })).toBeNull()
147
+ })
148
+
149
+ it('falls back to a remedy of its own when the backend sent no detail', () => {
150
+ const state = scopeAbsence({
151
+ access: readScopeAccess({ scope_access: { scoped_types: ['draft'], exempt: false, granted_paths: [] } }),
152
+ typeKey: 'draft',
153
+ readableCount: 0,
154
+ })
155
+ expect(state?.reason).toBe('no-grant')
156
+ expect(state?.why).toContain('An admin grants a scope')
157
+ })
158
+ })
159
+
160
+ describe('<ScopeAbsence />', () => {
161
+ it('renders the Absence, not the collection’s empty state', () => {
162
+ const { container } = render(
163
+ <ScopeAbsence
164
+ access={readScopeAccess(WIRE_NO_GRANT)}
165
+ typeKey="draft"
166
+ typeLabel="Draft"
167
+ readableCount={0}
168
+ fallback={<p>No results found.</p>}
169
+ />,
170
+ )
171
+ expect(container.querySelector('[data-scope-absence="no-grant"]')).toBeTruthy()
172
+ expect(
173
+ screen.getByText('This account holds no scope, so it reads no records'),
174
+ ).toBeInTheDocument()
175
+ expect(screen.queryByText('No results found.')).not.toBeInTheDocument()
176
+ })
177
+
178
+ it('hands back to the collection’s own empty state when scope is not the reason', () => {
179
+ render(
180
+ <ScopeAbsence access={null} typeKey="draft" readableCount={0} fallback={<p>No results found.</p>} />,
181
+ )
182
+ expect(screen.getByText('No results found.')).toBeInTheDocument()
183
+ })
184
+ })
185
+
186
+ describe('scopeNotice', () => {
187
+ const held = readScopeAccess(WIRE_SNAKE)
188
+
189
+ it('says which scope the counts cover, whether or not the view is empty', () => {
190
+ const note = scopeNotice({ access: held, typeKey: 'topic', typeLabel: 'Topic' })
191
+ expect(note).toContain('/ogmc-agent-test')
192
+ expect(note).toContain('not shown')
193
+ })
194
+
195
+ it('is silent for an ungated type, an exempt caller, and a caller holding nothing', () => {
196
+ expect(scopeNotice({ access: held, typeKey: 'news_item' })).toBeNull()
197
+ expect(scopeNotice({ access: null, typeKey: 'draft' })).toBeNull()
198
+ expect(
199
+ scopeNotice({
200
+ access: readScopeAccess({ scope_access: { scoped_types: ['draft'], exempt: true, granted_paths: ['/ogmc'] } }),
201
+ typeKey: 'draft',
202
+ }),
203
+ ).toBeNull()
204
+ // Holding nothing is the Absence's case; two statements of it is one too many.
205
+ expect(scopeNotice({ access: readScopeAccess(WIRE_NO_GRANT), typeKey: 'draft' })).toBeNull()
206
+ })
207
+
208
+ it('renders nothing at all when it has nothing to say', () => {
209
+ const { container } = render(<ScopeNotice access={held} typeKey="news_item" />)
210
+ expect(container.querySelector('[data-scope-notice]')).toBeNull()
211
+ const shown = render(<ScopeNotice access={held} typeKey="draft" typeLabel="Draft" />)
212
+ expect(shown.container.querySelector('[data-scope-notice="narrowed"]')).toBeTruthy()
213
+ })
214
+ })
@@ -8,3 +8,12 @@ export { FootnoteScope, Footnote } from './Footnote'
8
8
  export type { FootnoteScopeProps, FootnoteProps } from './Footnote'
9
9
  export { Absence, ABSENCE_DASH } from './Absence'
10
10
  export type { AbsenceProps, AbsenceTier } from './Absence'
11
+ export { ScopeAbsence, ScopeNotice } from './ScopeAbsence'
12
+ export type { ScopeAbsenceProps } from './ScopeAbsence'
13
+ export { readScopeAccess, scopeAbsence, scopeNotice } from './scope-access'
14
+ export type {
15
+ ScopeAccess,
16
+ ScopeAbsenceInput,
17
+ ScopeAbsenceReason,
18
+ ScopeAbsenceState,
19
+ } from './scope-access'
@@ -0,0 +1,221 @@
1
+ /**
2
+ * WHY A LIST IS EMPTY WHEN THE READER IS SCOPE-GATED (bd startsim-44bar).
3
+ *
4
+ * A foundry tenant may file rows of a type under a `scope` path and show a
5
+ * reader only the scopes they hold a grant on. When it does, every list
6
+ * response for a non-exempt caller carries a `scope_access` report — which
7
+ * types are gated, whether this caller is exempt, and which paths they hold
8
+ * (tenant-starter `apps/schema_registry/access.py::scope_access_report`, put on
9
+ * the envelope by `apps/api/pagination.py`).
10
+ *
11
+ * THE REPORT EXISTS BECAUSE `count: 0` IS AMBIGUOUS. "There is nothing here"
12
+ * and "you were never granted the scope this lives in" are the same bytes on
13
+ * the wire, and a UI that renders both as a bare empty table turns a
14
+ * provisioning gap into "the app lost our content" — the 2026-08-20 blackout
15
+ * shape, and the reason bd startsim-4ipm established that an empty read is an
16
+ * Absence and not a fallback. The backend already states the cause AND the
17
+ * remedy; this module is the half that puts it on screen.
18
+ *
19
+ * NOT THE SAME AXIS AS `@startsimpli/ui/scope`, and the two are easy to
20
+ * confuse. That module is the scope SWITCHER — which scope the reader has
21
+ * chosen to browse, bound to the query string. This one is the read GATE —
22
+ * which scopes the reader is ENTITLED to, which is the server's answer and not
23
+ * a choice. A switcher offering one scope and a gate granting one scope look
24
+ * alike on screen and fail completely differently: the first is a filter the
25
+ * reader can clear, the second is a permission they cannot.
26
+ *
27
+ * READ CAMEL-TOLERANTLY. The shared @startsimpli/api client camelCases response
28
+ * keys, so a browser fetch spells the report `scopeAccess.grantedPaths` while
29
+ * Django's own JSON, a server-side fetch and every backend test spell it
30
+ * `scope_access.granted_paths`. Both are the same report under two names — the
31
+ * same asymmetry `marketing-agents-web/src/lib/scope.ts` documents for
32
+ * `scope_path`. Values inside (`draft`, `/ogmc-agent-test`) are untransformed.
33
+ */
34
+
35
+ /** The `scope_access` report, normalised to one spelling. */
36
+ export interface ScopeAccess {
37
+ /** Type keys the `scope` axis gates for this caller, e.g. `['draft','scope','topic']`. */
38
+ scopedTypes: string[]
39
+ /**
40
+ * True only when the caller reads EVERY gated type regardless of grant.
41
+ *
42
+ * Note this is all but decorative on a LIST envelope: the tenant sets the key
43
+ * there only for a non-exempt caller, so it arrives `false` or not at all. It
44
+ * carries a real `true` on `/api/v1/whoami/`, where the report is unconditional.
45
+ */
46
+ exempt: boolean
47
+ /** The scope paths this caller holds a grant on. Empty is the blackout shape. */
48
+ grantedPaths: string[]
49
+ /** The backend's own sentence naming the cause and the remedy, when it sent one. */
50
+ detail?: string
51
+ }
52
+
53
+ function stringList(value: unknown): string[] {
54
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
55
+ }
56
+
57
+ function pick(source: Record<string, unknown>, ...names: string[]): unknown {
58
+ for (const name of names) {
59
+ if (source[name] !== undefined) return source[name]
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * The `scope_access` report carried by a list envelope, a `/whoami/` body, or
66
+ * the bare report itself — or `null` when there is none.
67
+ *
68
+ * `null` is the answer for every tenant that has not declared the axis and for
69
+ * every exempt caller, and it means "say nothing new": the ordinary empty state
70
+ * is the honest one there.
71
+ */
72
+ export function readScopeAccess(source: unknown): ScopeAccess | null {
73
+ if (!source || typeof source !== 'object') return null
74
+ const body = source as Record<string, unknown>
75
+ const raw = pick(body, 'scopeAccess', 'scope_access') ?? body
76
+ if (!raw || typeof raw !== 'object') return null
77
+ const report = raw as Record<string, unknown>
78
+ const scopedTypes = stringList(pick(report, 'scopedTypes', 'scoped_types'))
79
+ // A report with no gated types is not a report — this is what tells the bare
80
+ // report apart from an envelope that never carried one.
81
+ if (scopedTypes.length === 0) return null
82
+ const detail = pick(report, 'detail')
83
+ return {
84
+ scopedTypes,
85
+ exempt: pick(report, 'exempt') === true,
86
+ grantedPaths: stringList(pick(report, 'grantedPaths', 'granted_paths')),
87
+ ...(typeof detail === 'string' && detail.trim() ? { detail: detail.trim() } : {}),
88
+ }
89
+ }
90
+
91
+ /** Which of the two scope absences a reader is looking at. */
92
+ export type ScopeAbsenceReason =
93
+ /** Gated, and this account holds NO scope at all: a provisioning gap. */
94
+ | 'no-grant'
95
+ /** Gated, holds scopes, and nothing in them answers this view. */
96
+ | 'other-scope'
97
+
98
+ export interface ScopeAbsenceState {
99
+ reason: ScopeAbsenceReason
100
+ title: string
101
+ description: string
102
+ why: string
103
+ /** The gated type keys, for a caller that wants to say more. */
104
+ scopedTypes: string[]
105
+ /** The paths this account holds — empty on `no-grant`. */
106
+ grantedPaths: string[]
107
+ }
108
+
109
+ export interface ScopeAbsenceInput {
110
+ /** The report, as {@link readScopeAccess} returned it. */
111
+ access: ScopeAccess | null | undefined
112
+ /** The type this view lists, e.g. `draft`. */
113
+ typeKey: string
114
+ /** Its human label, e.g. `Draft`. Falls back to the key. */
115
+ typeLabel?: string
116
+ /**
117
+ * How many records of this type the reader can reach AT ALL — the server's
118
+ * own unfiltered count, NOT the length of the filtered view.
119
+ *
120
+ * The distinction is the whole difference between a disclosure and a nag. A
121
+ * reader holding a scope with four drafts in it, who then narrows to "last 7
122
+ * days" and matches none, has an empty view for a reason they chose and can
123
+ * see; blaming the scope gate there would put a permissions banner over an
124
+ * ordinary filter, teach the reader to ignore it, and retire the warning
125
+ * exactly where it matters. Only a reader who can reach NOTHING of the type
126
+ * is looking at the blackout shape. The standing narrowing — "your counts
127
+ * cover one scope" — is {@link scopeNotice}, which is always true and says so
128
+ * quietly instead.
129
+ */
130
+ readableCount: number
131
+ /** True while the read is still in flight. A pending read is never an absence. */
132
+ loading?: boolean
133
+ }
134
+
135
+ /**
136
+ * The absence a scope-gated reader is owed in place of an empty table, or
137
+ * `null` when the ordinary empty state is the truthful one.
138
+ *
139
+ * IT NEVER NAMES THE ROWS IT CANNOT SEE. The client is on the far side of the
140
+ * gate: it knows which scopes the reader holds and nothing whatever about what
141
+ * is filed elsewhere, so it says "only the scopes you hold are counted here"
142
+ * and never "these rows are in /ogmc". Inventing the other side would be a new
143
+ * bug wearing this one's fix.
144
+ *
145
+ * `null` for: a pending read, a reader who can reach any row of the type, no
146
+ * report, an exempt caller, and a type the axis does not gate — that last one
147
+ * matters, because the report rides on EVERY list a gated caller makes,
148
+ * including the ungated types, and a news feed of 6,456 rows must not grow a
149
+ * scope disclaimer when a filter empties it.
150
+ */
151
+ export function scopeAbsence({
152
+ access,
153
+ typeKey,
154
+ typeLabel,
155
+ readableCount,
156
+ loading,
157
+ }: ScopeAbsenceInput): ScopeAbsenceState | null {
158
+ if (loading) return null
159
+ if (readableCount > 0) return null
160
+ if (!access || access.exempt) return null
161
+ if (!access.scopedTypes.includes(typeKey)) return null
162
+
163
+ const label = (typeLabel || typeKey).toLowerCase()
164
+ const shared = { scopedTypes: access.scopedTypes, grantedPaths: access.grantedPaths }
165
+
166
+ if (access.grantedPaths.length === 0) {
167
+ return {
168
+ reason: 'no-grant',
169
+ title: 'This account holds no scope, so it reads no records',
170
+ description:
171
+ `${label} records in this workspace are filed by scope, and a reader sees only ` +
172
+ 'the scopes they were granted. This account was granted none — so the list ' +
173
+ 'is empty because of a missing grant, not because the workspace is empty.',
174
+ why:
175
+ access.detail ??
176
+ 'An admin grants a scope by sharing that scope’s own record with this account.',
177
+ ...shared,
178
+ }
179
+ }
180
+
181
+ const held = access.grantedPaths.join(', ')
182
+ return {
183
+ reason: 'other-scope',
184
+ title: 'Nothing here in the scopes this account holds',
185
+ description:
186
+ `${label} records in this workspace are filed by scope. This account reads ` +
187
+ `${held} and nothing else, so anything filed under another scope is neither ` +
188
+ 'shown nor counted here.',
189
+ why:
190
+ `Scopes held: ${held}. An admin widens this by sharing another scope’s own ` +
191
+ 'record with this account.',
192
+ ...shared,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * THE STANDING DISCLOSURE: one line saying a reader's counts cover only the
198
+ * scopes they hold — rendered whether or not the view is empty.
199
+ *
200
+ * The Absence above fires only in the blackout shape, and that leaves the
201
+ * commonest scoped reading unsaid: a member holding one scope of several sees
202
+ * "1 total" over a type of ninety and has no way to tell that from a workspace
203
+ * with one record in it. Nothing is WRONG on that screen, which is precisely
204
+ * why it needs a line rather than a banner — the number is true of what the
205
+ * reader may read, and the sentence says what that is.
206
+ *
207
+ * `null` when there is no report, when the caller is exempt, when the type is
208
+ * not gated, or when the caller holds no scope at all — that last one is the
209
+ * Absence's case, and saying both would say it twice.
210
+ */
211
+ export function scopeNotice({
212
+ access,
213
+ typeKey,
214
+ typeLabel,
215
+ }: Pick<ScopeAbsenceInput, 'access' | 'typeKey' | 'typeLabel'>): string | null {
216
+ if (!access || access.exempt) return null
217
+ if (!access.scopedTypes.includes(typeKey)) return null
218
+ if (access.grantedPaths.length === 0) return null
219
+ const label = (typeLabel || typeKey).toLowerCase()
220
+ return `Counts and rows here cover ${access.grantedPaths.join(', ')} only \u2014 ${label} records filed under any other scope are not shown.`
221
+ }