@ciromaciel/dashboard-kit 1.0.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.
Files changed (38) hide show
  1. package/README.md +40 -0
  2. package/package.json +46 -0
  3. package/src/auth-labels.js +77 -0
  4. package/src/components/ActiveResource.jsx +68 -0
  5. package/src/components/AuthLoader.jsx +66 -0
  6. package/src/components/AuthScreen.jsx +32 -0
  7. package/src/components/CardSkeleton.jsx +82 -0
  8. package/src/components/CommandCenter.css +57 -0
  9. package/src/components/CommandCenter.jsx +185 -0
  10. package/src/components/DashboardFrame.jsx +97 -0
  11. package/src/components/DataTable.jsx +153 -0
  12. package/src/components/DeleteModal.jsx +341 -0
  13. package/src/components/EmptyState.jsx +55 -0
  14. package/src/components/GroupLabel.jsx +28 -0
  15. package/src/components/HoldToConfirm.jsx +151 -0
  16. package/src/components/ListSkeleton.jsx +55 -0
  17. package/src/components/MenuItem.jsx +56 -0
  18. package/src/components/Metric.jsx +178 -0
  19. package/src/components/MoreResults.jsx +137 -0
  20. package/src/components/NavItem.jsx +57 -0
  21. package/src/components/PageHeader.jsx +124 -0
  22. package/src/components/PlanBanner.jsx +88 -0
  23. package/src/components/ResourceItem.jsx +93 -0
  24. package/src/components/SectionCard.jsx +212 -0
  25. package/src/components/SidebarShell.jsx +271 -0
  26. package/src/components/SupportModal.jsx +269 -0
  27. package/src/components/TableSkeleton.jsx +43 -0
  28. package/src/components/TableToolbar.jsx +136 -0
  29. package/src/components/Terminal.jsx +217 -0
  30. package/src/components/Wordmark.jsx +52 -0
  31. package/src/components/ZenLabel.jsx +30 -0
  32. package/src/components/notify-theme.js +103 -0
  33. package/src/components/notify.js +65 -0
  34. package/src/components/table-grid.js +27 -0
  35. package/src/hooks/useExclusiveModal.js +49 -0
  36. package/src/hooks/usePaginatedList.js +206 -0
  37. package/src/index.js +99 -0
  38. package/src/tokens.css +47 -0
@@ -0,0 +1,97 @@
1
+ import { AppShell, Box, Burger, Group } from '@mantine/core'
2
+
3
+ import { Wordmark } from './Wordmark'
4
+
5
+ /** A largura da coluna, em px. Igual nos sete painéis. */
6
+ export const SIDEBAR_WIDTH = 300
7
+
8
+ /**
9
+ * A moldura de todo painel: barra no celular, coluna lateral, palco.
10
+ *
11
+ * VEIO DE SETE CÓPIAS IDÊNTICAS. Cada painel repetia o mesmo `<AppShell>` com
12
+ * os mesmos números — inclusive o comentário que explica o `height` responsivo,
13
+ * copiado literalmente nos sete, que é a prova de que viajou por `cp`.
14
+ *
15
+ * O QUE FICA AQUI é a moldura. O que NÃO fica é a composição de cada produto:
16
+ * providers, boot de identidade, stores, quotas e modais continuam no painel,
17
+ * porque é onde vivem as decisões dele. A fronteira é essa — a moldura não sabe
18
+ * o que é um projeto.
19
+ *
20
+ * A ALTURA DO HEADER É 0 NO DESKTOP E 56 NO CELULAR, e isso não é estilo:
21
+ * `hiddenFrom` sozinho remove o elemento por CSS, mas o `AppShell` continua
22
+ * reservando os 56px de deslocamento no topo da coluna e do conteúdo — uma
23
+ * faixa vazia acima da marca. A altura responsiva zera o deslocamento onde o
24
+ * header não existe.
25
+ *
26
+ * O PALCO não usa o py de 80–120 da landing: lá ele separa seções de uma
27
+ * narrativa; aqui há uma tela só, com a coluna fixa ao lado. A respiração vira
28
+ * o padding do container (px 48 · pt 40 · pb 80 · maw 1280), e o ritmo interno
29
+ * vira o espaço entre blocos.
30
+ *
31
+ * @param {string} productName Nome do produto, para a marca na barra mobile.
32
+ * @param {boolean} mobileOpened Estado da gaveta no celular.
33
+ * @param {Function} onToggleMobile Abre/fecha a gaveta.
34
+ * @param {node} sidebar A coluna. Cada produto monta a sua.
35
+ * @param {node} children O palco — normalmente o `<Outlet>`.
36
+ */
37
+ export function DashboardFrame({ productName, mobileOpened, onToggleMobile, sidebar, children }) {
38
+ return (
39
+ <AppShell
40
+ header={{ height: { base: 56, sm: 0 }, offset: true }}
41
+ navbar={{
42
+ width: SIDEBAR_WIDTH,
43
+ breakpoint: 'sm',
44
+ collapsed: { mobile: !mobileOpened },
45
+ }}
46
+ padding={0}
47
+ transitionDuration={250}
48
+ transitionTimingFunction="ease"
49
+ >
50
+ {/*
51
+ A barra só existe no celular: no desktop a coluna já está visível,
52
+ e mais um header seria cromo duplicado.
53
+ */}
54
+ <AppShell.Header
55
+ withBorder
56
+ hiddenFrom="sm"
57
+ >
58
+ <Group
59
+ h="100%"
60
+ px="md"
61
+ gap="md"
62
+ wrap="nowrap"
63
+ >
64
+ <Burger
65
+ opened={mobileOpened}
66
+ onClick={onToggleMobile}
67
+ size="sm"
68
+ aria-label="Menu"
69
+ />
70
+ <Wordmark
71
+ name={productName}
72
+ size={20}
73
+ />
74
+ </Group>
75
+ </AppShell.Header>
76
+
77
+ <AppShell.Navbar
78
+ withBorder={false}
79
+ p={0}
80
+ >
81
+ {sidebar}
82
+ </AppShell.Navbar>
83
+
84
+ <AppShell.Main>
85
+ <Box
86
+ mx="auto"
87
+ px={{ base: 'md', md: 48 }}
88
+ pt={{ base: 24, md: 40 }}
89
+ pb={80}
90
+ maw={1280}
91
+ >
92
+ {children}
93
+ </Box>
94
+ </AppShell.Main>
95
+ </AppShell>
96
+ )
97
+ }
@@ -0,0 +1,153 @@
1
+ import { Box, Card, Text } from '@mantine/core'
2
+ import { useMediaQuery } from '@mantine/hooks'
3
+ import { templateFor, ROW_PADDING, HEAD_PADDING } from './table-grid'
4
+
5
+ /**
6
+ * The system's table — CSS Grid, not <Table>.
7
+ *
8
+ * Grid because the spec asks for two things that table layout makes hard:
9
+ * proportional columns (`2.4fr 1fr 1fr 40px`) and the WHOLE row clickable. Grid
10
+ * solves both without fighting display:table.
11
+ *
12
+ * Spec (§Table):
13
+ * container Card p={0} withBorder · overflow hidden
14
+ * header bg gray.1 · padding 12px 24px · borderBottom 1px gray.2
15
+ * header cell fz 10 · fw 800 · gray.5 · uppercase · ls 1.5px
16
+ * row padding 14px 24px · borderBottom 1px gray.2 (except the last)
17
+ * cell fz 13 · gray.7 · lh 1.4 — the "data" role
18
+ * hover background gray.1 · transition 120ms
19
+ * highlight background gray.1 + 2px black hairline on the left (inset)
20
+ * — `quietSelection` keeps only the background, for when the
21
+ * highlight is a state of the data and not the row you clicked
22
+ *
23
+ * Config-driven per Law 4: `columns` describes the columns with data
24
+ * (key/label/width), and each column brings its own `render`. The consumer
25
+ * never injects the table's structure — it only says what each cell shows.
26
+ */
27
+
28
+ const HEAD = { fz: 10, fw: 800, c: 'gray.5', tt: 'uppercase', ls: '1.5px', lh: 1.4 }
29
+
30
+ export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected, quietSelection, getRowLabel, toolbar, footer, emptyState, loading, skeleton }) {
31
+ const clickable = typeof onRowClick === 'function'
32
+
33
+ /*
34
+ * A column with `hideBelow` disappears on phones — and really disappears:
35
+ * hiding it via CSS would leave the grid track reserved, and an empty gash
36
+ * would remain in its place. JS is what decides: below 48em the hidden
37
+ * columns drop out of the list, so the grid is never even generated with
38
+ * them.
39
+ */
40
+ const wide = useMediaQuery('(min-width: 48em)', true)
41
+ const visible = wide ? columns : columns.filter(col => !col.hideBelow)
42
+ const template = templateFor(visible)
43
+
44
+ return (
45
+ <Card
46
+ p={0}
47
+ style={{ overflow: 'hidden' }}
48
+ >
49
+ {toolbar}
50
+
51
+ <Box
52
+ bg="gray.1"
53
+ style={{
54
+ display: 'grid',
55
+ gridTemplateColumns: template,
56
+ gap: 16,
57
+ alignItems: 'center',
58
+ padding: HEAD_PADDING,
59
+ borderBottom: 'var(--rlg-border)',
60
+ }}
61
+ >
62
+ {visible.map(col =>
63
+ col.label ? (
64
+ <Text
65
+ key={col.key}
66
+ {...HEAD}
67
+ ta={col.align}
68
+ >
69
+ {col.label}
70
+ </Text>
71
+ ) : (
72
+ <Box key={col.key} />
73
+ ),
74
+ )}
75
+ </Box>
76
+
77
+ {/* First load: the skeleton has the SAME grid as the table, so the
78
+ layout doesn't jump when the data arrives (Law 3). A refetch with
79
+ data already visible doesn't flash a skeleton over it — the
80
+ consumer decides, passing `loading` only the first time. */}
81
+ {loading && skeleton}
82
+
83
+ {!loading && rows.length === 0 && emptyState}
84
+
85
+ {!loading &&
86
+ rows.map((row, i) => {
87
+ const selected = isRowSelected?.(row) || undefined
88
+ const last = i === rows.length - 1
89
+
90
+ return (
91
+ <Box
92
+ key={getRowKey(row)}
93
+ data-selected={selected}
94
+ onClick={clickable ? () => onRowClick(row) : undefined}
95
+ // The row is the target: it has to announce itself to the
96
+ // keyboard and the screen reader as the button it actually is.
97
+ onKeyDown={
98
+ clickable
99
+ ? e => {
100
+ if (e.target !== e.currentTarget) return
101
+ if (e.key === 'Enter' || e.key === ' ') {
102
+ e.preventDefault()
103
+ onRowClick(row)
104
+ }
105
+ }
106
+ : undefined
107
+ }
108
+ onMouseEnter={clickable ? e => (e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-1)') : undefined}
109
+ onMouseLeave={clickable ? e => (e.currentTarget.style.backgroundColor = selected ? 'var(--mantine-color-gray-1)' : '') : undefined}
110
+ tabIndex={clickable ? 0 : undefined}
111
+ role={clickable ? 'button' : undefined}
112
+ aria-pressed={clickable && isRowSelected ? Boolean(selected) : undefined}
113
+ aria-label={clickable ? getRowLabel?.(row) : undefined}
114
+ style={{
115
+ display: 'grid',
116
+ gridTemplateColumns: template,
117
+ gap: 16,
118
+ alignItems: 'center',
119
+ padding: ROW_PADDING,
120
+ cursor: clickable ? 'pointer' : undefined,
121
+ borderBottom: last ? 'none' : 'var(--rlg-border)',
122
+ transition: 'background-color 120ms ease',
123
+ /* Selected: black hairline on the left. Not a strong
124
+ background color, which would compete with the hover.
125
+
126
+ `quietSelection` keeps the background and drops the
127
+ hairline. The 2px mark was designed for
128
+ SELECTION — the row you just clicked — and it
129
+ weighs too much when the highlight is a
130
+ permanent state of the data (the live version,
131
+ which already announces itself with the badge on
132
+ the row). */
133
+ backgroundColor: selected ? 'var(--mantine-color-gray-1)' : undefined,
134
+ boxShadow: selected && !quietSelection ? 'inset 2px 0 0 var(--mantine-color-gray-9)' : undefined,
135
+ }}
136
+ >
137
+ {visible.map(col => (
138
+ <Box
139
+ key={col.key}
140
+ miw={0}
141
+ style={col.align ? { textAlign: col.align } : undefined}
142
+ >
143
+ {col.render(row)}
144
+ </Box>
145
+ ))}
146
+ </Box>
147
+ )
148
+ })}
149
+
150
+ {footer}
151
+ </Card>
152
+ )
153
+ }
@@ -0,0 +1,341 @@
1
+ import { useState } from 'react'
2
+ import { Box, Button, Group, Modal, Stack, Text, TextInput, Title } from '@mantine/core'
3
+ import { IconAlertTriangle, IconArrowRight } from '@tabler/icons-react'
4
+
5
+ /**
6
+ * The destructive modal — one shape for the seven panels.
7
+ *
8
+ * Specified in `design-system/10-modal-de-exclusao.md`. This is the executable
9
+ * half: the spec says WHY, the component is WHAT, and having only one of them
10
+ * is how the two drift apart.
11
+ *
12
+ * WHAT THE AUDIT FOUND
13
+ *
14
+ * Fifteen destructive modals across seven panels, and no two alike. Four
15
+ * families of chrome (`padding={0}` bare vs Mantine's `title` prop vs a
16
+ * `ThemeIcon` in the header vs nothing), the destructive button on the left in
17
+ * two and on the right in thirteen, four ways of comparing the typed string
18
+ * (case-sensitive, case-insensitive, trimmed on one side, trimmed on both), and
19
+ * the target repeated in the label AND the placeholder in nine of them.
20
+ *
21
+ * None of that variety encoded a difference in the product. It encoded the
22
+ * order the screens were written in.
23
+ *
24
+ * THE ONE THING THIS COMPONENT REFUSES TO PARAMETERIZE
25
+ *
26
+ * The row icon. It is `IconArrowRight`, always, and there is no prop for it.
27
+ * When it was a choice, three panels picked thematic glyphs — a key for OAuth,
28
+ * a globe for a domain, a clock for history — and each of those repeats in a
29
+ * picture what the sentence beside it already says, turning a list into four
30
+ * marks competing for the same eye. The glyph MARKS a row; it does not describe
31
+ * it. Making it a prop is how it stops being a rule.
32
+ *
33
+ * WHEN NOT TO REACH FOR THIS
34
+ *
35
+ * The escalation has three steps and this is the top one. A reversible action
36
+ * (disable, archive, unlink) takes a plain button. An irreversible action whose
37
+ * TARGET is unambiguous takes `HoldToConfirmIcon` — holding proves intent,
38
+ * which is the whole question when there is only one row it could be. This
39
+ * modal exists for the case where the target itself can be confused with its
40
+ * neighbours, because only typing an identifier answers "this one?".
41
+ *
42
+ * Do not stack the hold and the typing. Two panels do, and the second gate adds
43
+ * friction without adding information: the modal is already the escalation.
44
+ */
45
+
46
+ /**
47
+ * The title's size follows the LENGTH OF THE DATA, not the viewport.
48
+ *
49
+ * A panel title is fixed at 28px because the panel's width is controlled. That
50
+ * reasoning holds for a title someone WROTE. It breaks for a title that IS an
51
+ * identifier, because its length is not ours to choose — the e-mails in one
52
+ * Auth application run from 22 to 56 characters, and half pass 30.
53
+ *
54
+ * Past 40 the identifier leaves the title slot entirely. Shrinking it was the
55
+ * first attempt and it failed in a way worth recording: at 18px mono a
56
+ * 56-character address STILL wrapped, and it broke mid-word
57
+ * (`…myinfrast / ructure`) while the confirmation line below, at 13px, fit the
58
+ * same string whole. The title had become the worse copy of something the modal
59
+ * already showed intact one block down.
60
+ *
61
+ * So above 40 the heading becomes the sentence that says what is about to
62
+ * happen, and the identifier appears once — whole, in mono, directly above the
63
+ * field that asks for it.
64
+ *
65
+ * Truncating with an ellipsis is never the answer: this string is what someone
66
+ * checks character by character before reproducing it.
67
+ */
68
+ export function deleteTitleScale(value) {
69
+ const length = (value ?? '').length
70
+
71
+ if (length <= 24) return { fz: { base: 22, sm: 28 }, asTitle: true }
72
+ if (length <= 40) return { fz: { base: 18, sm: 22 }, asTitle: true }
73
+
74
+ return { fz: { base: 18, sm: 20 }, asTitle: false }
75
+ }
76
+
77
+ /**
78
+ * @param {object} props
79
+ * @param {boolean} props.opened
80
+ * @param {Function} props.onClose
81
+ * @param {Function} props.onConfirm Receives the confirmed target string.
82
+ * @param {string} props.target The identifier to be typed. Required.
83
+ * @param {string} props.context Subtitle: who · what · where.
84
+ * @param {string} props.headline Title when `target` is too long to be one.
85
+ * @param {string[]} props.consequences One sentence per row. Includes the caveat.
86
+ * @param {string} props.confirmLabel Button verb — names the resource.
87
+ * @param {string} props.prompt Sentence above the target line.
88
+ * @param {boolean} props.loading
89
+ * @param {boolean} props.caseSensitive See `matches` below.
90
+ * @param {string} props.eyebrow
91
+ */
92
+ export function DeleteModal({
93
+ opened,
94
+ onClose,
95
+ onConfirm,
96
+ target,
97
+ context,
98
+ headline = 'Confirmar exclusão',
99
+ consequences = [],
100
+ confirmLabel = 'Excluir',
101
+ prompt = 'Digite o texto abaixo para confirmar',
102
+ loading = false,
103
+ caseSensitive = false,
104
+ eyebrow = 'Zona de risco',
105
+ }) {
106
+ const [typed, setTyped] = useState('')
107
+
108
+ const value = (target ?? '').trim()
109
+ const scale = deleteTitleScale(value)
110
+
111
+ /*
112
+ * `trim()` always; the case follows the DATA.
113
+ *
114
+ * A stray space — a paste, a phone's autocorrect — is not an attempt to
115
+ * delete something else. The capital letter is the judgement call, so it is
116
+ * the caller's: an e-mail is case-insensitive by RFC and the worker stores
117
+ * it lowercased, so refusing `Maciel@…` would be rigour against someone
118
+ * typing their own address correctly. A project id or a slug is not, and
119
+ * there `caseSensitive` keeps the comparison exact.
120
+ *
121
+ * `value.length > 0` stops an empty field from matching an absent target
122
+ * and arming the button.
123
+ */
124
+ const entered = typed.trim()
125
+ const matches = value.length > 0 && (caseSensitive ? entered === value : entered.toLowerCase() === value.toLowerCase())
126
+ const canDelete = matches && !loading
127
+
128
+ const confirm = () => {
129
+ if (canDelete) onConfirm(value)
130
+ }
131
+
132
+ return (
133
+ <Modal
134
+ opened={opened}
135
+ // Closing mid-request does not cancel the deletion — it only hides
136
+ // the result of it.
137
+ onClose={loading ? () => {} : onClose}
138
+ size={520}
139
+ padding={0}
140
+ withCloseButton={false}
141
+ closeOnClickOutside={!loading}
142
+ closeOnEscape={!loading}
143
+ >
144
+ <Stack
145
+ gap={0}
146
+ p={{ base: 24, md: 32 }}
147
+ >
148
+ <Group
149
+ gap={8}
150
+ wrap="nowrap"
151
+ align="center"
152
+ >
153
+ <IconAlertTriangle
154
+ size={13}
155
+ stroke={2.5}
156
+ style={{ color: 'var(--mantine-color-red-6)', flex: 'none' }}
157
+ />
158
+ <Text
159
+ fz={10}
160
+ fw={800}
161
+ c="red.6"
162
+ tt="uppercase"
163
+ ls="1.5px"
164
+ >
165
+ {eyebrow}
166
+ </Text>
167
+ </Group>
168
+
169
+ {/*
170
+ * The TARGET is the title, and the subtitle says what kind of
171
+ * thing it is — until the target is too long to be a title, and
172
+ * then the two swap jobs.
173
+ *
174
+ * The identifier, not the human name: names repeat inside one
175
+ * account, and the identifier is the string to be typed below.
176
+ */}
177
+ <Title
178
+ order={2}
179
+ fw={900}
180
+ fz={scale.fz}
181
+ lh={1.15}
182
+ mt={12}
183
+ ls="-0.04em"
184
+ c="gray.9"
185
+ /*
186
+ * `break-all` only where the title IS the identifier. On the
187
+ * long case the title is prose, and `break-all` would split
188
+ * it mid-word — the rule exists for a string with no spaces
189
+ * to break at.
190
+ */
191
+ style={scale.asTitle ? { wordBreak: 'break-all' } : undefined}
192
+ >
193
+ {scale.asTitle ? value : headline}
194
+ </Title>
195
+
196
+ {context ? (
197
+ <Text
198
+ fz={12}
199
+ c="gray.6"
200
+ ff="var(--mantine-font-family-monospace)"
201
+ mt={6}
202
+ >
203
+ {context}
204
+ </Text>
205
+ ) : null}
206
+
207
+ {consequences.length > 0 ? (
208
+ <Box mt={20}>
209
+ <Stack gap={12}>
210
+ {consequences.map(label => (
211
+ <Group
212
+ key={label}
213
+ gap={10}
214
+ wrap="nowrap"
215
+ align="flex-start"
216
+ >
217
+ <IconArrowRight
218
+ size={14}
219
+ stroke={1.5}
220
+ style={{ color: 'var(--mantine-color-gray-4)', flex: 'none', marginTop: 3 }}
221
+ />
222
+ <Text
223
+ fz={13}
224
+ c="gray.6"
225
+ lh={1.5}
226
+ >
227
+ {label}
228
+ </Text>
229
+ </Group>
230
+ ))}
231
+ </Stack>
232
+ </Box>
233
+ ) : null}
234
+
235
+ {/*
236
+ * No rule before the confirmation — the footer's is the only
237
+ * one. Two hairlines inside 520px cut the modal into three
238
+ * strips and the second lands on nothing: typing the target and
239
+ * pressing the button are one gesture, not two sections.
240
+ */}
241
+ <Box mt={28}>
242
+ {/*
243
+ * The instruction is prose; the TARGET gets its own line.
244
+ *
245
+ * Embedded mid-sentence, a long identifier breaks the phrase
246
+ * across two lines and the eye has to reassemble it around
247
+ * the very string it is copying.
248
+ *
249
+ * And this is the LAST place it appears. It used to be here
250
+ * AND in the placeholder — the same string twice, with the
251
+ * placeholder truncated by the input's width. A cut-off copy
252
+ * of what must be typed exactly teaches the error.
253
+ */}
254
+ <Text
255
+ fz={13}
256
+ c="gray.7"
257
+ mb={6}
258
+ >
259
+ {prompt}
260
+ </Text>
261
+ <Text
262
+ fz={13}
263
+ fw={800}
264
+ c="gray.9"
265
+ ff="var(--mantine-font-family-monospace)"
266
+ mb={8}
267
+ // One click selects the whole identifier. Copying is not
268
+ // cheating: the protection is against deleting the WRONG
269
+ // row, and pasted text is the right one.
270
+ style={{ wordBreak: 'break-all', userSelect: 'all' }}
271
+ >
272
+ {value}
273
+ </Text>
274
+
275
+ <TextInput
276
+ value={typed}
277
+ onChange={event => setTyped(event.currentTarget.value)}
278
+ aria-label={`${prompt}: ${value}`}
279
+ // An identifier is data, not prose: the keyboard's
280
+ // helpers get in the way of reproducing an exact string.
281
+ autoComplete="off"
282
+ autoCorrect="off"
283
+ autoCapitalize="off"
284
+ spellCheck={false}
285
+ data-autofocus
286
+ disabled={loading}
287
+ /*
288
+ * Monospace on the INPUT only. `ff` on the component
289
+ * lands on the wrapper and takes the prose with it.
290
+ */
291
+ styles={{ input: { fontFamily: 'var(--mantine-font-family-monospace)' } }}
292
+ onKeyDown={event => {
293
+ // Enter completes what the field already authorized.
294
+ // Typing IS the confirmation; routing the person to
295
+ // the mouse afterwards is friction with no
296
+ // protective role.
297
+ if (event.key === 'Enter') confirm()
298
+ }}
299
+ />
300
+ </Box>
301
+ </Stack>
302
+
303
+ {/*
304
+ * The footer is a band of its own and carries ONLY the buttons. It
305
+ * is also the modal's only rule: above it everything that is read
306
+ * and typed, below it the act.
307
+ *
308
+ * Destructive on the LEFT — §Formulários: "Submit à direita;
309
+ * destrutivo à esquerda". The submit position is reserved for what
310
+ * moves forward, and this does the opposite.
311
+ */}
312
+ <Box
313
+ px={{ base: 24, md: 32 }}
314
+ py={20}
315
+ style={{ borderTop: 'var(--rlg-border)' }}
316
+ >
317
+ <Group
318
+ justify="space-between"
319
+ gap={8}
320
+ >
321
+ <Button
322
+ color="red.7"
323
+ onClick={confirm}
324
+ disabled={!canDelete}
325
+ loading={loading}
326
+ >
327
+ {confirmLabel}
328
+ </Button>
329
+ <Button
330
+ variant="subtle"
331
+ color="gray.7"
332
+ onClick={onClose}
333
+ disabled={loading}
334
+ >
335
+ Cancelar
336
+ </Button>
337
+ </Group>
338
+ </Box>
339
+ </Modal>
340
+ )
341
+ }
@@ -0,0 +1,55 @@
1
+ import { Box, Button, Stack, Text } from '@mantine/core'
2
+
3
+ /**
4
+ * Empty is not an error — it's a screen that doesn't have content yet.
5
+ *
6
+ * Spec (§States): Stack align center py 56 · title fz 10 fw 800 gray.4
7
+ * uppercase ls 1.5px + subtitle + action.
8
+ *
9
+ * Two anatomies, the same shape: "no data at all" and "the search found
10
+ * nothing", the latter quoting the term — whoever searched needs to see what
11
+ * was searched for.
12
+ */
13
+ export function EmptyState({ title, description, actionLabel, onAction }) {
14
+ return (
15
+ <Stack
16
+ align="center"
17
+ gap={8}
18
+ py={56}
19
+ px="md"
20
+ >
21
+ <Text
22
+ fz={10}
23
+ fw={800}
24
+ c="gray.4"
25
+ tt="uppercase"
26
+ ls="1.5px"
27
+ ta="center"
28
+ >
29
+ {title}
30
+ </Text>
31
+ {description && (
32
+ <Text
33
+ fz={13}
34
+ c="gray.6"
35
+ lh={1.5}
36
+ ta="center"
37
+ maw={420}
38
+ >
39
+ {description}
40
+ </Text>
41
+ )}
42
+ {actionLabel && onAction && (
43
+ <Box mt={8}>
44
+ <Button
45
+ variant="outline"
46
+ color="gray.9"
47
+ onClick={onAction}
48
+ >
49
+ {actionLabel}
50
+ </Button>
51
+ </Box>
52
+ )}
53
+ </Stack>
54
+ )
55
+ }
@@ -0,0 +1,28 @@
1
+ import { Text } from '@mantine/core'
2
+
3
+ /**
4
+ * O rótulo que nomeia um grupo na coluna — "Projetos", "Instâncias", "Conta".
5
+ *
6
+ * Seis cópias, e a única diferença real entre elas era o `lineClamp` que o
7
+ * Functions não tinha. Ele entra: um nome de grupo que quebra em duas linhas
8
+ * empurra a lista inteira, e nenhum destes rótulos precisa de duas linhas.
9
+ *
10
+ * O tamanho de 10px com `lts` de 1.5px é o papel de RÓTULO do sistema Zen —
11
+ * ele anuncia a seção sem competir com o conteúdo dela.
12
+ */
13
+ export function GroupLabel({ children }) {
14
+ return (
15
+ <Text
16
+ fz={10}
17
+ fw={800}
18
+ c="gray.4"
19
+ tt="uppercase"
20
+ px={12}
21
+ mb={4}
22
+ lineClamp={1}
23
+ lts="1.5px"
24
+ >
25
+ {children}
26
+ </Text>
27
+ )
28
+ }