@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.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.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.test.d.ts} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApprovalMethod,
|
|
3
|
+
Resource,
|
|
4
|
+
} from '@igstack/app-catalog-backend-core'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
ROUTE_FREE,
|
|
8
|
+
ROUTE_REQUEST,
|
|
9
|
+
ROUTE_UNDOCUMENTED,
|
|
10
|
+
accessTabLabel,
|
|
11
|
+
} from '~/modules/appCatalog/utils/accessTabLabel'
|
|
12
|
+
|
|
13
|
+
const methods: ApprovalMethod[] = [
|
|
14
|
+
{
|
|
15
|
+
slug: 'access-bot',
|
|
16
|
+
type: 'service',
|
|
17
|
+
displayName: 'Access Bot',
|
|
18
|
+
shortName: 'Bot',
|
|
19
|
+
config: { url: 'https://chat.example/bot' },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
slug: 'long-desk',
|
|
23
|
+
type: 'service',
|
|
24
|
+
// No shortName — the tab has to fall back to this, however long it is.
|
|
25
|
+
displayName: 'Enterprise Business Service Organization',
|
|
26
|
+
config: {},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
slug: 'open',
|
|
30
|
+
type: 'noAccessRequired',
|
|
31
|
+
displayName: 'No Approval Needed',
|
|
32
|
+
config: {},
|
|
33
|
+
},
|
|
34
|
+
{ slug: 'custom', type: 'custom', displayName: 'Custom', config: {} },
|
|
35
|
+
{ slug: 'unknown', type: 'unknown', displayName: 'Unknown', config: {} },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const res = (over: Partial<Resource>): Resource => ({
|
|
39
|
+
id: 'x',
|
|
40
|
+
slug: 'x',
|
|
41
|
+
displayName: 'X',
|
|
42
|
+
...over,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('accessTabLabel', () => {
|
|
46
|
+
it('names a service desk by its shortName', () => {
|
|
47
|
+
const label = accessTabLabel(
|
|
48
|
+
res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
|
|
49
|
+
methods,
|
|
50
|
+
)
|
|
51
|
+
expect(label.route).toBe('Bot')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('falls back to displayName when no shortName is set', () => {
|
|
55
|
+
const label = accessTabLabel(
|
|
56
|
+
res({ accessRequest: { approvalMethodSlug: 'long-desk' } }),
|
|
57
|
+
methods,
|
|
58
|
+
)
|
|
59
|
+
expect(label.route).toBe('Enterprise Business Service Organization')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('says Free when no request is needed', () => {
|
|
63
|
+
const label = accessTabLabel(
|
|
64
|
+
res({ accessRequest: { approvalMethodSlug: 'open' } }),
|
|
65
|
+
methods,
|
|
66
|
+
)
|
|
67
|
+
expect(label.route).toBe(ROUTE_FREE)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('says not documented for an entry with no accessRequest at all', () => {
|
|
71
|
+
expect(accessTabLabel(res({}), methods).route).toBe(ROUTE_UNDOCUMENTED)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('says not documented for an explicitly unknown method', () => {
|
|
75
|
+
const label = accessTabLabel(
|
|
76
|
+
res({ accessRequest: { approvalMethodSlug: 'unknown' } }),
|
|
77
|
+
methods,
|
|
78
|
+
)
|
|
79
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// A bare `custom` names no channel and writes down no steps, so it is the
|
|
83
|
+
// same dead end as `unknown` under a different slug.
|
|
84
|
+
it('says not documented for a bare custom method', () => {
|
|
85
|
+
const label = accessTabLabel(
|
|
86
|
+
res({ accessRequest: { approvalMethodSlug: 'custom' } }),
|
|
87
|
+
methods,
|
|
88
|
+
)
|
|
89
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A slug that resolves to no registered method. The access section renders an
|
|
93
|
+
// essentially empty box in that case, so the tab must not advertise a route.
|
|
94
|
+
it('says not documented when the method slug resolves to nothing', () => {
|
|
95
|
+
const label = accessTabLabel(
|
|
96
|
+
res({ accessRequest: { approvalMethodSlug: 'gone-away' } }),
|
|
97
|
+
methods,
|
|
98
|
+
)
|
|
99
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('still says request for a dangling slug that does write its steps down', () => {
|
|
103
|
+
const label = accessTabLabel(
|
|
104
|
+
res({
|
|
105
|
+
accessRequest: {
|
|
106
|
+
approvalMethodSlug: 'gone-away',
|
|
107
|
+
comments: 'Ask whoever owns it; the channel moved.',
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
methods,
|
|
111
|
+
)
|
|
112
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('says request for a custom method that does write its steps down', () => {
|
|
116
|
+
const label = accessTabLabel(
|
|
117
|
+
res({
|
|
118
|
+
accessRequest: {
|
|
119
|
+
approvalMethodSlug: 'custom',
|
|
120
|
+
comments: 'Ask the team that owns it.',
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
methods,
|
|
124
|
+
)
|
|
125
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('says 2 steps for a custom method with follow-up work after approval', () => {
|
|
129
|
+
const label = accessTabLabel(
|
|
130
|
+
res({
|
|
131
|
+
accessRequest: {
|
|
132
|
+
approvalMethodSlug: 'custom',
|
|
133
|
+
comments: 'Ask the owning team.',
|
|
134
|
+
postApprovalInstructions: 'Then install the client.',
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
methods,
|
|
138
|
+
)
|
|
139
|
+
expect(label.route).toBe('2 steps')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Children keep access as top-level fields rather than an accessRequest; the
|
|
143
|
+
// card's access section already falls back to them, so the tab must agree or
|
|
144
|
+
// the label contradicts the panel underneath it.
|
|
145
|
+
it('treats a child with only contacts as a request, not as undocumented', () => {
|
|
146
|
+
const label = accessTabLabel(
|
|
147
|
+
res({ parentSlug: 'p', approverSlugs: ['team-a'] }),
|
|
148
|
+
methods,
|
|
149
|
+
)
|
|
150
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('the chip', () => {
|
|
154
|
+
it('counts roles', () => {
|
|
155
|
+
const label = accessTabLabel(
|
|
156
|
+
res({
|
|
157
|
+
accessRequest: {
|
|
158
|
+
approvalMethodSlug: 'access-bot',
|
|
159
|
+
roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
methods,
|
|
163
|
+
)
|
|
164
|
+
expect(label.chip).toBe('2 roles')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('singularises one role', () => {
|
|
168
|
+
const label = accessTabLabel(
|
|
169
|
+
res({
|
|
170
|
+
accessRequest: {
|
|
171
|
+
approvalMethodSlug: 'access-bot',
|
|
172
|
+
roles: [{ displayName: 'Read' }],
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
methods,
|
|
176
|
+
)
|
|
177
|
+
expect(label.chip).toBe('1 role')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// The named channel stays as the route and the shape rides in the chip:
|
|
181
|
+
// "there is more to do after approval" is the thing worth knowing on an
|
|
182
|
+
// entry that already tells you where to ask.
|
|
183
|
+
it('prefers the step count over the role count when both apply', () => {
|
|
184
|
+
const label = accessTabLabel(
|
|
185
|
+
res({
|
|
186
|
+
accessRequest: {
|
|
187
|
+
approvalMethodSlug: 'access-bot',
|
|
188
|
+
requestPrompt: 'Requesting access.',
|
|
189
|
+
postApprovalInstructions: 'Then run the CLI login.',
|
|
190
|
+
roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
methods,
|
|
194
|
+
)
|
|
195
|
+
expect(label).toEqual({ route: 'Bot', chip: '2 steps' })
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('is absent when there is nothing to count', () => {
|
|
199
|
+
const label = accessTabLabel(
|
|
200
|
+
res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
|
|
201
|
+
methods,
|
|
202
|
+
)
|
|
203
|
+
expect(label.chip).toBeUndefined()
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -81,7 +81,7 @@ describe('Header login affordance', () => {
|
|
|
81
81
|
|
|
82
82
|
it('still shows the user menu once authenticated', () => {
|
|
83
83
|
state.isAuthenticated = true
|
|
84
|
-
state.user = { name: 'Igor Golovin', email: '
|
|
84
|
+
state.user = { name: 'Igor Golovin', email: 'igor@example.com' }
|
|
85
85
|
render(<Header />)
|
|
86
86
|
|
|
87
87
|
expect(screen.getByTestId('user-avatar-button')).toBeInTheDocument()
|
package/src/index.css
CHANGED
|
@@ -249,9 +249,16 @@
|
|
|
249
249
|
padding-left: 1.25rem;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/*
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
/* Review labels. Plain CSS rather than utilities: there is no success token in the
|
|
253
|
+
palette, and a tinted chip needs a colour the theme does not name. `--chart-3` is
|
|
254
|
+
the palette's green and already shifts for dark mode.
|
|
255
|
+
|
|
256
|
+
Mix percentages are low on purpose. At 75%/72% these measured 3.55:1 and 3.24:1
|
|
257
|
+
against the cream page — under the 4.5:1 floor for 11px text — and the old 600/700
|
|
258
|
+
weights were compensating for that, which is why they shouted and were still hard
|
|
259
|
+
to read. Lowering the mix raises contrast in both themes, so there is no .dark
|
|
260
|
+
override. Verify any change by measurement, not by eye: 60%/55% looks fine and
|
|
261
|
+
still fails. */
|
|
255
262
|
.comment-chip {
|
|
256
263
|
display: inline-flex;
|
|
257
264
|
align-items: center;
|
|
@@ -259,12 +266,13 @@
|
|
|
259
266
|
border-radius: 999px;
|
|
260
267
|
padding: 0.05rem 0.5rem;
|
|
261
268
|
font-size: 0.6875rem;
|
|
262
|
-
font-weight:
|
|
269
|
+
font-weight: 500;
|
|
263
270
|
line-height: 1.5;
|
|
264
271
|
white-space: nowrap;
|
|
265
272
|
}
|
|
266
273
|
.comment-chip-applied {
|
|
267
|
-
|
|
274
|
+
/* 5.46:1 light, 7.67:1 dark */
|
|
275
|
+
color: color-mix(in oklab, var(--chart-3) 50%, var(--foreground));
|
|
268
276
|
background: color-mix(in oklab, var(--chart-3) 16%, var(--card));
|
|
269
277
|
border: 1px solid color-mix(in oklab, var(--chart-3) 35%, var(--card));
|
|
270
278
|
}
|
|
@@ -275,6 +283,262 @@
|
|
|
275
283
|
font-weight: 400;
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/* One register for every section label inside the detail card.
|
|
287
|
+
Measured: the old heading was 13px/500 above 13.5px/400 body — a single
|
|
288
|
+
100-unit weight step as the ONLY distinction, with no size, tracking or case
|
|
289
|
+
change. It read as "too pale" while already measuring 14.45:1 against the
|
|
290
|
+
card, so darkening was never available; the register was the problem.
|
|
291
|
+
Four signals now instead of one. `--foreground` deliberately: muting drops it
|
|
292
|
+
to 5.46:1 on a label whose complaint was paleness. Margins carry the rest
|
|
293
|
+
(28px between sections, 6px to the label's own body) and there are no rules
|
|
294
|
+
per section — a tab panel's edge already states that boundary. */
|
|
295
|
+
.section-label {
|
|
296
|
+
font-size: 0.6875rem;
|
|
297
|
+
font-weight: 700;
|
|
298
|
+
letter-spacing: 0.06em;
|
|
299
|
+
text-transform: uppercase;
|
|
300
|
+
color: var(--foreground);
|
|
301
|
+
line-height: 1.4;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* The way into the app: icon, name and address fused into ONE pressable
|
|
305
|
+
surface at the top of the detail card. Plain CSS, not utilities — every
|
|
306
|
+
colour here is a measured `color-mix`, and the arbitrary-value classes that
|
|
307
|
+
would be needed are exactly what the JIT can miss.
|
|
308
|
+
|
|
309
|
+
Padding is always present and cancelled by an equal negative margin, so the
|
|
310
|
+
surface arriving on hover never nudges the text inside it. Only colour,
|
|
311
|
+
border, shadow and a 2px lift animate. */
|
|
312
|
+
.app-door {
|
|
313
|
+
display: inline-flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.9375rem;
|
|
316
|
+
max-width: calc(100% + 2.125rem);
|
|
317
|
+
margin: -0.6875rem -1.0625rem;
|
|
318
|
+
padding: 0.6875rem 1.0625rem;
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
border-radius: 0.875rem;
|
|
321
|
+
/* A whisper at rest, so the block reads as a surface — therefore pressable —
|
|
322
|
+
before anyone hovers it. Most first-time visitors scan rather than hover. */
|
|
323
|
+
background: color-mix(in oklab, var(--primary) 2.5%, var(--card));
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
color: inherit;
|
|
326
|
+
cursor: pointer;
|
|
327
|
+
transition:
|
|
328
|
+
background-color 0.14s ease-out,
|
|
329
|
+
border-color 0.14s ease-out,
|
|
330
|
+
box-shadow 0.14s ease-out,
|
|
331
|
+
translate 0.14s ease-out;
|
|
332
|
+
}
|
|
333
|
+
.app-door:hover,
|
|
334
|
+
.app-door:focus-visible {
|
|
335
|
+
background: color-mix(in oklab, var(--primary) 7%, var(--card));
|
|
336
|
+
border-color: color-mix(in oklab, var(--primary) 38%, var(--border));
|
|
337
|
+
box-shadow: 0 5px 16px -7px
|
|
338
|
+
color-mix(in oklab, var(--primary) 45%, transparent);
|
|
339
|
+
translate: 0 -2px;
|
|
340
|
+
outline: none;
|
|
341
|
+
}
|
|
342
|
+
.app-door:focus-visible {
|
|
343
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
|
|
344
|
+
}
|
|
345
|
+
.app-door:active {
|
|
346
|
+
translate: 0 0;
|
|
347
|
+
}
|
|
348
|
+
/* The icon lifts WITH the surface, so the logo reads as part of the press
|
|
349
|
+
rather than something parked beside it. Shadow only, deliberately: the icon
|
|
350
|
+
is either an <img> (no background to tint) or a monogram whose colour is set
|
|
351
|
+
as an INLINE style, which a class cannot override — and recolouring a brand
|
|
352
|
+
logo on hover would be wrong even where it worked. */
|
|
353
|
+
.app-door__icon {
|
|
354
|
+
transition: box-shadow 0.14s ease-out;
|
|
355
|
+
}
|
|
356
|
+
.app-door:hover .app-door__icon,
|
|
357
|
+
.app-door:focus-visible .app-door__icon {
|
|
358
|
+
box-shadow: 0 2px 8px -3px
|
|
359
|
+
color-mix(in oklab, var(--primary) 55%, transparent);
|
|
360
|
+
}
|
|
361
|
+
/* The title must NOT turn brand-coloured on hover. Measured against the hover
|
|
362
|
+
tint: `--foreground` holds 13.35:1 light / 12.60:1 dark, where the darkened
|
|
363
|
+
brand colour manages only 5.17:1 / 3.65:1 — so tinting it DROPPED contrast on
|
|
364
|
+
interaction. Hover, active and focus have to increase contrast, never reduce
|
|
365
|
+
it. The surface, border, shadow, icon tile and glyph already carry the state
|
|
366
|
+
change; the brand tint was the only cue that worked by going paler. */
|
|
367
|
+
.app-door__title {
|
|
368
|
+
display: block;
|
|
369
|
+
font-family: var(--font-serif);
|
|
370
|
+
font-size: 1.5625rem;
|
|
371
|
+
font-weight: 600;
|
|
372
|
+
line-height: 1.2;
|
|
373
|
+
}
|
|
374
|
+
/* One size step past the point where the longest names run the surface past
|
|
375
|
+
the card. Only a handful of entries are this long, but they include some of
|
|
376
|
+
the most-opened ones. */
|
|
377
|
+
.app-door__title--long {
|
|
378
|
+
font-size: 1.3125rem;
|
|
379
|
+
}
|
|
380
|
+
.app-door__url {
|
|
381
|
+
display: block;
|
|
382
|
+
margin-top: 0.25rem;
|
|
383
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
384
|
+
font-size: 0.75rem;
|
|
385
|
+
line-height: 1.4;
|
|
386
|
+
color: var(--muted-foreground);
|
|
387
|
+
overflow: hidden;
|
|
388
|
+
text-overflow: ellipsis;
|
|
389
|
+
white-space: nowrap;
|
|
390
|
+
transition: color 0.14s ease-out;
|
|
391
|
+
}
|
|
392
|
+
.app-door:hover .app-door__url,
|
|
393
|
+
.app-door:focus-visible .app-door__url {
|
|
394
|
+
color: var(--foreground);
|
|
395
|
+
}
|
|
396
|
+
.app-door__glyph {
|
|
397
|
+
flex: none;
|
|
398
|
+
color: var(--muted-foreground);
|
|
399
|
+
opacity: 0.75;
|
|
400
|
+
transition:
|
|
401
|
+
opacity 0.14s ease-out,
|
|
402
|
+
color 0.14s ease-out,
|
|
403
|
+
translate 0.14s ease-out;
|
|
404
|
+
}
|
|
405
|
+
.app-door:hover .app-door__glyph,
|
|
406
|
+
.app-door:focus-visible .app-door__glyph {
|
|
407
|
+
opacity: 1;
|
|
408
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
409
|
+
translate: 2px -2px;
|
|
410
|
+
}
|
|
411
|
+
/* No hover on a touch screen, so a hover-only affordance is invisible there.
|
|
412
|
+
Show the surface at full strength instead. */
|
|
413
|
+
@media (hover: none) {
|
|
414
|
+
.app-door {
|
|
415
|
+
background: color-mix(in oklab, var(--primary) 6%, var(--card));
|
|
416
|
+
border-color: color-mix(in oklab, var(--primary) 30%, var(--border));
|
|
417
|
+
}
|
|
418
|
+
.app-door__glyph {
|
|
419
|
+
opacity: 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* The detail card's hero band: identity, the way in, and the typed-id jumps,
|
|
424
|
+
with the tab strip seated on its bottom edge so the two read as one unit and
|
|
425
|
+
the panel below reads as contents. The tint is faint on purpose — the access
|
|
426
|
+
section carries a stronger one, and two competing tinted panels in the first
|
|
427
|
+
screen was the objection that nearly sank this layout. */
|
|
428
|
+
.detail-band {
|
|
429
|
+
background: color-mix(in oklab, var(--primary) 4.5%, var(--card));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* The tab strip. Horizontally scrollable rather than wrapping: a wrapped strip
|
|
433
|
+
changes the band's height from one entry to the next. */
|
|
434
|
+
.detail-tabs {
|
|
435
|
+
display: flex;
|
|
436
|
+
gap: 2px;
|
|
437
|
+
border-bottom: 1px solid var(--border);
|
|
438
|
+
overflow-x: auto;
|
|
439
|
+
scrollbar-width: none;
|
|
440
|
+
}
|
|
441
|
+
.detail-tabs::-webkit-scrollbar {
|
|
442
|
+
display: none;
|
|
443
|
+
}
|
|
444
|
+
.detail-tab {
|
|
445
|
+
position: relative;
|
|
446
|
+
display: inline-flex;
|
|
447
|
+
align-items: center;
|
|
448
|
+
gap: 0.4375rem;
|
|
449
|
+
padding: 0.625rem 0.875rem 0.6875rem;
|
|
450
|
+
border: 0;
|
|
451
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
452
|
+
background: none;
|
|
453
|
+
font-size: 0.84375rem;
|
|
454
|
+
font-weight: 600;
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
color: var(--muted-foreground);
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
transition:
|
|
459
|
+
color 0.14s ease-out,
|
|
460
|
+
background-color 0.14s ease-out;
|
|
461
|
+
}
|
|
462
|
+
.detail-tab:hover {
|
|
463
|
+
color: var(--foreground);
|
|
464
|
+
background: color-mix(in oklab, var(--primary) 5%, transparent);
|
|
465
|
+
}
|
|
466
|
+
/* The marker sits ON the strip's own border, so the active tab reads as
|
|
467
|
+
attached to the panel below rather than underlined. */
|
|
468
|
+
.detail-tab::after {
|
|
469
|
+
content: '';
|
|
470
|
+
position: absolute;
|
|
471
|
+
left: 0.625rem;
|
|
472
|
+
right: 0.625rem;
|
|
473
|
+
bottom: -1px;
|
|
474
|
+
height: 2.5px;
|
|
475
|
+
border-radius: 2px;
|
|
476
|
+
background: transparent;
|
|
477
|
+
transition: background-color 0.14s ease-out;
|
|
478
|
+
}
|
|
479
|
+
.detail-tab[aria-selected='true'] {
|
|
480
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
481
|
+
}
|
|
482
|
+
.detail-tab[aria-selected='true']::after {
|
|
483
|
+
background: color-mix(in oklab, var(--primary) 85%, black);
|
|
484
|
+
}
|
|
485
|
+
.detail-tab:focus-visible {
|
|
486
|
+
outline: 2px solid color-mix(in oklab, var(--primary) 85%, black);
|
|
487
|
+
outline-offset: -2px;
|
|
488
|
+
}
|
|
489
|
+
/* Measured: `--muted-foreground` on `--secondary` at this size is 4.65:1 light
|
|
490
|
+
— over the 4.5:1 floor by 0.15, which is not a margin. Mixed toward
|
|
491
|
+
`--foreground` instead: 8.16:1 light, 8.24:1 dark. The active chip formerly
|
|
492
|
+
used the brand colour on a brand tint, which measured 4.46:1 light and
|
|
493
|
+
3.00:1 dark — failing — so the tint alone now marks "active" and the text
|
|
494
|
+
stays at full `--foreground`. Weight is for emphasis; it is not contrast. */
|
|
495
|
+
.detail-tab__chip {
|
|
496
|
+
border-radius: 999px;
|
|
497
|
+
padding: 2px 7px;
|
|
498
|
+
font-size: 0.65625rem;
|
|
499
|
+
font-weight: 700;
|
|
500
|
+
line-height: 1.3;
|
|
501
|
+
background: var(--secondary);
|
|
502
|
+
color: color-mix(in oklab, var(--foreground) 55%, var(--muted-foreground));
|
|
503
|
+
}
|
|
504
|
+
.detail-tab[aria-selected='true'] .detail-tab__chip {
|
|
505
|
+
background: color-mix(in oklab, var(--primary) 16%, transparent);
|
|
506
|
+
color: var(--foreground);
|
|
507
|
+
}
|
|
508
|
+
/* "Something here is unreviewed", visible from any tab. Decorative, so it is
|
|
509
|
+
exempt on text-contrast grounds — and never the only carrier: the tab's
|
|
510
|
+
accessible name says it too. */
|
|
511
|
+
.detail-tab__dot {
|
|
512
|
+
width: 7px;
|
|
513
|
+
height: 7px;
|
|
514
|
+
flex: none;
|
|
515
|
+
border-radius: 999px;
|
|
516
|
+
background: var(--chart-4, oklch(0.72 0.16 70));
|
|
517
|
+
box-shadow: 0 0 0 2px
|
|
518
|
+
color-mix(in oklab, var(--chart-4, oklch(0.72 0.16 70)) 25%, transparent);
|
|
519
|
+
|
|
520
|
+
/* How many items are still waiting on a maintainer. Amber rather than red: an entry
|
|
521
|
+
with open feedback is disputed, not broken, and red is already the withdraw
|
|
522
|
+
affordance in the same thread. 5.45:1 light, 8.08:1 dark. */
|
|
523
|
+
.feedback-open-count {
|
|
524
|
+
display: inline-flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
justify-content: center;
|
|
527
|
+
min-width: 1.15rem;
|
|
528
|
+
border-radius: 999px;
|
|
529
|
+
padding: 0.15rem 0.35rem;
|
|
530
|
+
font-size: 0.6875rem;
|
|
531
|
+
font-weight: 600;
|
|
532
|
+
line-height: 1;
|
|
533
|
+
letter-spacing: normal;
|
|
534
|
+
text-transform: none;
|
|
535
|
+
white-space: nowrap;
|
|
536
|
+
color: color-mix(in oklab, var(--chart-4) 45%, var(--foreground));
|
|
537
|
+
background: color-mix(in oklab, var(--chart-4) 18%, var(--card));
|
|
538
|
+
border: 1px solid color-mix(in oklab, var(--chart-4) 38%, var(--card));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
278
542
|
/* tw-animate-css ships no reduced-motion rules of its own, so honour the
|
|
279
543
|
preference here — the overlay animations it provides are all decorative. */
|
|
280
544
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
2
|
import { X } from 'lucide-react'
|
|
3
|
-
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from 'react'
|
|
4
4
|
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
5
5
|
import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
|
|
6
6
|
import { AppDetails } from '../detail/AppDetails'
|
|
@@ -38,6 +38,47 @@ export function AppDetailPanel({
|
|
|
38
38
|
const { approvalMethods } = useAppCatalogContext()
|
|
39
39
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Centre the card ONCE when it opens, then hold that position.
|
|
43
|
+
*
|
|
44
|
+
* `my-auto` centred it on every render, so each panel height change pushed both
|
|
45
|
+
* edges and half the delta became upward movement of everything above it —
|
|
46
|
+
* including the tab strip, which moved 113px and took the control the user had
|
|
47
|
+
* just clicked with it (CLS 0.19, "needs improvement").
|
|
48
|
+
*
|
|
49
|
+
* Top-anchoring fixed the shift but looked wrong: a short card sat high on the
|
|
50
|
+
* page. So pin the centred offset instead. The card still opens centred, and
|
|
51
|
+
* anything that grows it afterwards extends downward only — which is what a
|
|
52
|
+
* reader expects when they open a tab with more in it.
|
|
53
|
+
*
|
|
54
|
+
* Only on open and on resize. Deliberately NOT on content change: reacting to
|
|
55
|
+
* content is precisely the bug.
|
|
56
|
+
*/
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
const card = dialogRef.current
|
|
59
|
+
const scroller = card?.parentElement
|
|
60
|
+
if (!card || !scroller) return
|
|
61
|
+
|
|
62
|
+
const pinCentre = () => {
|
|
63
|
+
card.style.marginTop = '0px'
|
|
64
|
+
const style = getComputedStyle(scroller)
|
|
65
|
+
const usable =
|
|
66
|
+
scroller.clientHeight -
|
|
67
|
+
parseFloat(style.paddingTop) -
|
|
68
|
+
parseFloat(style.paddingBottom)
|
|
69
|
+
const slack = usable - card.offsetHeight
|
|
70
|
+
// Negative slack means the card is already taller than the viewport; leave it
|
|
71
|
+
// at the top and let the overlay scroll, rather than pushing its head off.
|
|
72
|
+
card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pinCentre()
|
|
76
|
+
window.addEventListener('resize', pinCentre)
|
|
77
|
+
return () => window.removeEventListener('resize', pinCentre)
|
|
78
|
+
// Keyed on the opened entry: a different card is a new open, and should
|
|
79
|
+
// re-centre for its own height.
|
|
80
|
+
}, [app.slug, subResource?.slug])
|
|
81
|
+
|
|
41
82
|
// Focus the dialog on mount so Esc hotkeys fire immediately, even when
|
|
42
83
|
// the card was opened by a mouse click (which leaves focus on the grid row).
|
|
43
84
|
//
|
|
@@ -75,7 +116,10 @@ export function AppDetailPanel({
|
|
|
75
116
|
aria-modal="true"
|
|
76
117
|
aria-label={`${subResource?.displayName ?? app.displayName} details`}
|
|
77
118
|
tabIndex={-1}
|
|
78
|
-
|
|
119
|
+
// Centred on open, then held — see the pin effect above. `my-auto` is
|
|
120
|
+
// deliberately absent: it re-centres on EVERY height change, which is the
|
|
121
|
+
// layout-shift bug.
|
|
122
|
+
className="relative w-full max-w-[min(1120px,94vw)] rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none"
|
|
79
123
|
>
|
|
80
124
|
<button
|
|
81
125
|
type="button"
|
|
@@ -31,6 +31,9 @@ export function AccessPrerequisiteChain({
|
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
33
|
<div
|
|
34
|
+
// Part of the access story, so it carries the same anchor the instructions
|
|
35
|
+
// do — a reader (and a test) wants the whole chain, not half of it.
|
|
36
|
+
data-access-section=""
|
|
34
37
|
className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4"
|
|
35
38
|
role="note"
|
|
36
39
|
aria-label="Access requires prerequisite steps"
|
|
@@ -235,10 +235,15 @@ const UNDOCUMENTED_WITHOUT_CONTACT =
|
|
|
235
235
|
*/
|
|
236
236
|
function AccessBox({ children }: { children: ReactNode }) {
|
|
237
237
|
return (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
// No frame and no heading. Both existed to mark this out as THE thing on a long
|
|
239
|
+
// scrolling card; now it is its own tab, so the tab's label already says what
|
|
240
|
+
// this is and the panel edge already bounds it. A box inside a box, under a
|
|
241
|
+
// heading that repeats the tab you just clicked, is two statements of the same
|
|
242
|
+
// fact and ink the reader has to look past.
|
|
243
|
+
// A stable anchor for tests, because neither of the old handles survives: the
|
|
244
|
+
// heading is gone, and a sub-resource page has no tab panel to read either.
|
|
245
|
+
// Text and structure both move; this does not.
|
|
246
|
+
<div data-access-section="" className="space-y-4">
|
|
242
247
|
{children}
|
|
243
248
|
</div>
|
|
244
249
|
)
|
|
@@ -104,7 +104,10 @@ export function SubResourceDetailPanel({
|
|
|
104
104
|
|
|
105
105
|
{/* Two-step access section */}
|
|
106
106
|
{hasAnyAccess && (
|
|
107
|
-
|
|
107
|
+
// The whole two-step region carries the anchor: the step labels naming
|
|
108
|
+
// the parent live out here, not inside the nested AccessRequestSections,
|
|
109
|
+
// so anchoring only those dropped the parent from the chain.
|
|
110
|
+
<div data-access-section="" className="mt-6 space-y-6">
|
|
108
111
|
<h3 className="text-sm font-medium">How to get access</h3>
|
|
109
112
|
|
|
110
113
|
{hasTwoStepAccess ? (
|