@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,136 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { ActionIcon, Group, Kbd, Text, TextInput, Tooltip } from '@mantine/core'
3
+ import { IconSearch, IconX } from '@tabler/icons-react'
4
+
5
+ /**
6
+ * A list's search — the DataTable's `toolbar`.
7
+ *
8
+ * WHERE IT LIVES, AND WHY THAT MATTERS
9
+ *
10
+ * Inside the Card, above the header, as the table's first band. The Storage put
11
+ * the field OUTSIDE the Card, in a `<Box>` with `borderBottom: 'none'` to fake
12
+ * being inside — a false frame that went out of alignment at the first
13
+ * breakpoint. The DataTable's `toolbar` exists precisely for this: the search is
14
+ * part of the table, not a loose block above it.
15
+ *
16
+ * IT STAYS ON SCREEN EVEN WITH AN EMPTY LIST. If the search disappeared along
17
+ * with the results, someone who typed a term with no matches would lose the
18
+ * very field they had just typed into and would have no way to fix it — short
19
+ * of reloading.
20
+ *
21
+ * THE SUPERSET OF THREE SEARCHES
22
+ *
23
+ * This is the fourth implementation, and the last: it replaces the Monitors'
24
+ * `BarraFerramentas` and the `Filter` components from auth and storage. Each of
25
+ * them had something the others didn't:
26
+ *
27
+ * - from Monitors come `Escape` to clear and the "X de Y" label
28
+ * - from the `Filter` components comes the ⌘K shortcut with focus, and the
29
+ * `Kbd` that announces it
30
+ * - the icon is 16px in all three (Functions used 14 in a hand-written search)
31
+ *
32
+ * ONLY ONE COMPONENT PER SCREEN MAY REGISTER ⌘K. Two listeners compete for
33
+ * focus and the outcome depends on mount order. On a screen with a global
34
+ * search (`PageHeader withSearch`), this search comes with `shortcut={false}`.
35
+ *
36
+ * @param {string} value - The term. Controlled by the consumer.
37
+ * @param {function} onChange - Receives the new term, and '' on clear.
38
+ * @param {string} placeholder - Say WHICH fields the search runs over
39
+ * ("Buscar por nome ou e-mail…"); "Buscar…"
40
+ * leaves nobody knowing what to try.
41
+ * @param {string} label - The field's `aria-label`.
42
+ * @param {boolean} shortcut - Registers ⌘K. See the paragraph above.
43
+ * @param {number} shown/total - The "X de Y". Only appears when it filters
44
+ * something.
45
+ * @param {node} children - The screen's filters (a sort Select, state
46
+ * chips). They sit to the right of the field.
47
+ */
48
+ export function TableToolbar({ value = '', onChange, placeholder = 'Buscar…', label = 'Buscar', shortcut = true, shown, total, children }) {
49
+ const inputRef = useRef(null)
50
+
51
+ useEffect(() => {
52
+ if (!shortcut) return
53
+ const aoTeclar = e => {
54
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
55
+ e.preventDefault()
56
+ inputRef.current?.focus()
57
+ }
58
+ }
59
+ window.addEventListener('keydown', aoTeclar)
60
+ return () => window.removeEventListener('keydown', aoTeclar)
61
+ }, [shortcut])
62
+
63
+ const limpar = () => {
64
+ onChange('')
65
+ inputRef.current?.focus()
66
+ }
67
+
68
+ // The "X de Y" only informs when the two numbers DISAGREE. Equal, it
69
+ // repeats what the list already shows and becomes noise in place of data.
70
+ const showsCount = Number.isFinite(shown) && Number.isFinite(total) && shown !== total
71
+
72
+ return (
73
+ <Group
74
+ px={{ base: 14, sm: 24 }}
75
+ py={12}
76
+ justify="space-between"
77
+ wrap="nowrap"
78
+ gap={16}
79
+ style={{ borderBottom: 'var(--rlg-border)' }}
80
+ >
81
+ <TextInput
82
+ ref={inputRef}
83
+ value={value}
84
+ onChange={e => onChange(e?.currentTarget?.value ?? '')}
85
+ onKeyDown={e => e.key === 'Escape' && limpar()}
86
+ placeholder={placeholder}
87
+ aria-label={label}
88
+ flex={1}
89
+ miw={0}
90
+ leftSection={
91
+ <IconSearch
92
+ size={16}
93
+ stroke={1.5}
94
+ />
95
+ }
96
+ rightSection={
97
+ value ? (
98
+ <Tooltip label="Limpar (Esc)">
99
+ <ActionIcon
100
+ size="sm"
101
+ variant="transparent"
102
+ color="gray"
103
+ onClick={limpar}
104
+ aria-label="Limpar busca"
105
+ >
106
+ <IconX
107
+ size={16}
108
+ stroke={1.5}
109
+ />
110
+ </ActionIcon>
111
+ </Tooltip>
112
+ ) : shortcut ? (
113
+ <Kbd>⌘K</Kbd>
114
+ ) : null
115
+ }
116
+ styles={{ input: { fontSize: 13 } }}
117
+ />
118
+
119
+ {children}
120
+
121
+ {showsCount && (
122
+ <Text
123
+ fz={11}
124
+ fw={800}
125
+ c="gray.4"
126
+ tt="uppercase"
127
+ ls="1.5px"
128
+ style={{ flexShrink: 0 }}
129
+ visibleFrom="sm"
130
+ >
131
+ {shown} de {total}
132
+ </Text>
133
+ )}
134
+ </Group>
135
+ )
136
+ }
@@ -0,0 +1,217 @@
1
+ import { Box, Group, CopyButton, Tooltip, ActionIcon } from '@mantine/core'
2
+ import { IconCopy, IconCheck } from '@tabler/icons-react'
3
+
4
+ /**
5
+ * Terminal — a superfície escura onde o código vive.
6
+ *
7
+ * VEIO DE CINCO CÓPIAS, E DUAS ERAM BYTE-IDÊNTICAS.
8
+ *
9
+ * Storage e Messages tinham o mesmo arquivo, `md5` a `md5`. Payments diferia em
10
+ * ZERO linhas de código (só comentários em PT vs EN). Hoster e Auth eram a
11
+ * mesma coisa sem o `TerminalOutput`, e o Hoster trazia a única evolução real:
12
+ * `line` como token em vez de literal.
13
+ *
14
+ * Este arquivo é o superset — a versão com `TerminalOutput` e com o token.
15
+ *
16
+ * Espelha o `CodeTerminal` de @ciromaciel/components-kit (o mesmo que a landing
17
+ * usa em FeaturesGrid layout="split"), para que painel e site mostrem código
18
+ * com a mesma cara. Os valores vieram de lá, não foram escolhidos aqui.
19
+ *
20
+ * O fundo é gray.9 — a única superfície escura do sistema. Ela se justifica
21
+ * porque código é conteúdo citado, não interface: o contraste invertido marca
22
+ * "isto é a máquina falando", e é o mesmo tratamento de qualquer terminal que
23
+ * o desenvolvedor já tem aberto ao lado.
24
+ */
25
+ const DARK = {
26
+ shell: 'var(--mantine-color-gray-9)',
27
+ bar: 'rgba(255, 255, 255, 0.06)',
28
+ /*
29
+ * O token vem de `tokens.css` do kit, com fallback ao literal que as
30
+ * cópias usavam. `var()` sem valor e sem fallback simplesmente não aplica
31
+ * a propriedade — o filete sumiria em silêncio no painel que ainda não
32
+ * define a variável, que é como o `--rlg-border` já quase se perdeu.
33
+ */
34
+ line: 'var(--rlg-terminal-rule, rgba(255, 255, 255, 0.10))',
35
+ dot: 'rgba(255, 255, 255, 0.22)',
36
+ label: 'rgba(255, 255, 255, 0.55)',
37
+ text: 'rgba(255, 255, 255, 0.92)',
38
+ }
39
+
40
+ export function Terminal({ children, label, copy = true, actions, style }) {
41
+ const code = typeof children === 'string' ? children : String(children ?? '')
42
+
43
+ // Linhas de shell (`$ …`) copiam só o comando: quem cola no terminal não
44
+ // quer o prompt junto. Sem prompt, copia tudo.
45
+ const prompts = code
46
+ .split('\n')
47
+ .filter(l => l.trimStart().startsWith('$ '))
48
+ .map(l => l.trimStart().slice(2))
49
+ const copyValue = prompts.length ? prompts.join('\n') : code
50
+
51
+ return (
52
+ <Box
53
+ style={{
54
+ border: '1px solid var(--mantine-color-gray-9)',
55
+ background: DARK.shell,
56
+ overflow: 'hidden',
57
+ ...style,
58
+ }}
59
+ >
60
+ <Group
61
+ justify="space-between"
62
+ wrap="nowrap"
63
+ px={14}
64
+ py={8}
65
+ style={{ borderBottom: `1px solid ${DARK.line}`, background: DARK.bar }}
66
+ >
67
+ <Group
68
+ gap={6}
69
+ wrap="nowrap"
70
+ miw={0}
71
+ >
72
+ {[0, 1, 2].map(i => (
73
+ <Box
74
+ key={i}
75
+ w={9}
76
+ h={9}
77
+ style={{ borderRadius: '50%', background: DARK.dot, flex: 'none' }}
78
+ />
79
+ ))}
80
+ {label && (
81
+ <Box
82
+ component="span"
83
+ ml={8}
84
+ style={{
85
+ fontFamily: 'var(--mantine-font-family-monospace)',
86
+ fontSize: 11,
87
+ letterSpacing: '0.5px',
88
+ color: DARK.label,
89
+ whiteSpace: 'nowrap',
90
+ overflow: 'hidden',
91
+ textOverflow: 'ellipsis',
92
+ }}
93
+ >
94
+ {label}
95
+ </Box>
96
+ )}
97
+ </Group>
98
+
99
+ <Group
100
+ gap={4}
101
+ wrap="nowrap"
102
+ style={{ flex: 'none' }}
103
+ >
104
+ {actions}
105
+ {copy && (
106
+ <CopyButton
107
+ value={copyValue}
108
+ timeout={1600}
109
+ >
110
+ {({ copied, copy: doCopy }) => (
111
+ <Tooltip
112
+ label={copied ? 'Copiado' : 'Copiar'}
113
+ withArrow
114
+ position="left"
115
+ >
116
+ <ActionIcon
117
+ variant="subtle"
118
+ size="sm"
119
+ radius="0"
120
+ onClick={doCopy}
121
+ aria-label="Copiar código"
122
+ style={{ color: copied ? DARK.text : DARK.label }}
123
+ >
124
+ {copied ? (
125
+ <IconCheck
126
+ size={14}
127
+ stroke={1.5}
128
+ />
129
+ ) : (
130
+ <IconCopy
131
+ size={14}
132
+ stroke={1.5}
133
+ />
134
+ )}
135
+ </ActionIcon>
136
+ </Tooltip>
137
+ )}
138
+ </CopyButton>
139
+ )}
140
+ </Group>
141
+ </Group>
142
+
143
+ <Box
144
+ component="pre"
145
+ m={0}
146
+ px={18}
147
+ py={16}
148
+ style={{
149
+ fontFamily: 'var(--mantine-font-family-monospace)',
150
+ fontSize: 12.5,
151
+ lineHeight: 1.65,
152
+ color: DARK.text,
153
+ whiteSpace: 'pre',
154
+ overflowX: 'auto',
155
+ tabSize: 2,
156
+ }}
157
+ >
158
+ {code}
159
+ </Box>
160
+ </Box>
161
+ )
162
+ }
163
+
164
+ /** Painel de saída, sob o terminal: mesma família, tinta de resultado. */
165
+ export function TerminalOutput({ children, label = 'output', tone = 'default' }) {
166
+ const color = tone === 'error' ? 'var(--mantine-color-red-4)' : DARK.text
167
+
168
+ return (
169
+ <Box
170
+ style={{
171
+ border: '1px solid var(--mantine-color-gray-9)',
172
+ borderTop: 'none',
173
+ background: DARK.shell,
174
+ overflow: 'hidden',
175
+ }}
176
+ >
177
+ <Box
178
+ px={14}
179
+ py={8}
180
+ style={{ borderBottom: `1px solid ${DARK.line}`, background: DARK.bar }}
181
+ >
182
+ <Box
183
+ component="span"
184
+ style={{
185
+ fontFamily: 'var(--mantine-font-family-monospace)',
186
+ fontSize: 10,
187
+ fontWeight: 800,
188
+ letterSpacing: '1.5px',
189
+ textTransform: 'uppercase',
190
+ color: tone === 'error' ? 'var(--mantine-color-red-4)' : DARK.label,
191
+ }}
192
+ >
193
+ {label}
194
+ </Box>
195
+ </Box>
196
+
197
+ <Box
198
+ component="pre"
199
+ m={0}
200
+ px={18}
201
+ py={14}
202
+ mah={180}
203
+ style={{
204
+ fontFamily: 'var(--mantine-font-family-monospace)',
205
+ fontSize: 11.5,
206
+ lineHeight: 1.6,
207
+ color,
208
+ whiteSpace: 'pre-wrap',
209
+ wordBreak: 'break-word',
210
+ overflowY: 'auto',
211
+ }}
212
+ >
213
+ {children}
214
+ </Box>
215
+ </Box>
216
+ )
217
+ }
@@ -0,0 +1,52 @@
1
+ import { Text } from '@mantine/core'
2
+
3
+ /**
4
+ * The product's wordmark, in two roles.
5
+ *
6
+ * `variant="mark"` is the large signature — the waiting screen, the top of an
7
+ * empty page. `variant="label"` is the small label in the sidebar, which says
8
+ * WHERE you are without competing with the content.
9
+ *
10
+ * THE NAME COMES IN AS A PROP, and that is the only difference from the four
11
+ * copies this component replaces. In those, the name was a literal in the JSX —
12
+ * which forced every dashboard to maintain the whole file just to change one
13
+ * word, and that is how the four diverged in quotes, comments and styling
14
+ * details.
15
+ *
16
+ * `name` IS REQUIRED, and the component shouts when it's missing. Without that
17
+ * warning, an undefined `name` renders an empty <Text>: no build error, no
18
+ * console error, and the wordmark simply vanishes from the top of the sidebar.
19
+ * That is what happened in four dashboards when the kit's re-export replaced the
20
+ * local copies, which carried the literal name — and it was only discovered by
21
+ * looking at the screen.
22
+ *
23
+ * This also replaces the `logotipo.webp` image some sidebars were loading. The
24
+ * `<Image>` carries a fixed `alt`, so any load failure printed the raw word at
25
+ * the top of the dashboard. With no image there is no alt to leak — and the
26
+ * wordmark follows the theme instead of being a fixed-size bitmap.
27
+ */
28
+ export function Wordmark({ name, variant = 'mark', size, ...props }) {
29
+ const isLabel = variant === 'label'
30
+
31
+ // A warning, not an exception: taking down the whole dashboard over the
32
+ // wordmark would be worse than the wordmark being missing. But it can't go
33
+ // by in silence either.
34
+ if (!name && typeof console !== 'undefined') {
35
+ console.warn('[dashboard-kit] <Wordmark> without `name`: the wordmark renders empty. Pass the product name.')
36
+ }
37
+
38
+ return (
39
+ <Text
40
+ fz={size ?? (isLabel ? 11 : 28)}
41
+ fw={isLabel ? 800 : 900}
42
+ tt={isLabel ? 'uppercase' : undefined}
43
+ ls={isLabel ? '1.5px' : '-0.04em'}
44
+ lh={isLabel ? 1.4 : 1}
45
+ c={isLabel ? 'gray.4' : 'gray.9'}
46
+ ta="center"
47
+ {...props}
48
+ >
49
+ {name}
50
+ </Text>
51
+ )
52
+ }
@@ -0,0 +1,30 @@
1
+ import { Text } from '@mantine/core'
2
+
3
+ /**
4
+ * The uppercase label — the light half of the canonical pair.
5
+ *
6
+ * Spec v1.0.0 (§Tokens — Typography, the "Label" role):
7
+ * 11px · fw 800 · uppercase · ls 1.5px · gray.4 · lh 1.4
8
+ *
9
+ * gray.4 fails AA for body text (2.6:1) and is therefore only legitimate here:
10
+ * short text, in caps and weight 800, redundant with the title right below it.
11
+ * Never in a paragraph — the audit checks exactly that.
12
+ *
13
+ * The color is NOT parameterizable on purpose: when it was, three screens
14
+ * started using gray.5 and the label stopped having a single color.
15
+ */
16
+ export function ZenLabel({ children, ...props }) {
17
+ return (
18
+ <Text
19
+ fz={11}
20
+ fw={800}
21
+ tt="uppercase"
22
+ ls="1.5px"
23
+ c="gray.4"
24
+ lh={1.4}
25
+ {...props}
26
+ >
27
+ {children}
28
+ </Text>
29
+ )
30
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The shape of a notification — what used to live copy-pasted across five theme.js files.
3
+ *
4
+ * Three pieces, meant to be used together:
5
+ *
6
+ * NOTIFICATIONS_PROPS the props for <Notifications /> (position and duration)
7
+ * notificationTheme the `Notification` override in the Mantine theme
8
+ * NOTIFY_DESCRIPTION_CSS the CSS rule that inline `styles` cannot express
9
+ *
10
+ * Consumers wire it up like this:
11
+ *
12
+ * import { NOTIFICATIONS_PROPS, notificationTheme } from '@ciromaciel/dashboard-kit'
13
+ * createTheme({ components: { ...notificationTheme } })
14
+ * <Notifications {...NOTIFICATIONS_PROPS} />
15
+ */
16
+
17
+ /**
18
+ * Where the stack appears and how long it stays.
19
+ *
20
+ * `bottom-right` for the whole stack. The top-right corner is where the header
21
+ * lives — the user card in the dashboards, the menu on the landings — and the
22
+ * notification used to be born right on top of it. Bottom-right competes with
23
+ * nothing, and it is where the eye already looks for confirmation of an action.
24
+ *
25
+ * The 5s is a system decision, not something inherited from the lib: Mantine's
26
+ * default is 4s, too short for a full sentence in Portuguese. Where the prop was
27
+ * missing — auth and storage — the message vanished a second before the rest of
28
+ * the house.
29
+ */
30
+ export const NOTIFICATIONS_PROPS = {
31
+ position: 'bottom-right',
32
+ autoClose: 5000,
33
+ }
34
+
35
+ /**
36
+ * The surface: 1px hairline, square corners, no shadow. The card does not float.
37
+ *
38
+ * The icon disc stays FILLED — `backgroundColor` is deliberately left untouched
39
+ * here so that Mantine paints it with the state color. The white glyph comes
40
+ * from notify.js. An earlier version of this file forced
41
+ * `backgroundColor: transparent`; if anyone reintroduces that, the white glyph
42
+ * becomes invisible against the card's white.
43
+ *
44
+ * `withCloseButton` is Mantine's default and stays on: an error is the case
45
+ * where someone wants to read at their own pace and dismiss when done, without
46
+ * waiting out the 5s.
47
+ */
48
+ export const notificationTheme = {
49
+ Notification: {
50
+ defaultProps: {
51
+ withBorder: true,
52
+ radius: 0,
53
+ classNames: { description: 'rlg-notification-description' },
54
+ },
55
+ styles: theme => ({
56
+ root: {
57
+ boxShadow: 'none',
58
+ border: `1px solid ${theme.colors.gray[2]}`,
59
+ '--notification-radius': '0',
60
+ },
61
+ icon: {
62
+ width: 28,
63
+ height: 28,
64
+ marginInlineEnd: 'var(--mantine-spacing-sm)',
65
+ /*
66
+ * The glyph is white HERE, and not only in notify.js, because
67
+ * not every notification goes through it: dozens of legacy calls
68
+ * hand-build `notifications.show({ icon: <IconX size={16} /> })`
69
+ * with no color. Without this line the SVG inherits the state
70
+ * color and turns into an icon the same color as its own disc —
71
+ * the ~1:1 contrast this file exists to prevent.
72
+ *
73
+ * `currentColor` is what Tabler uses by default for `stroke`, so
74
+ * painting the inherited color is enough.
75
+ */
76
+ color: 'var(--mantine-color-white)',
77
+ },
78
+ title: {
79
+ fontWeight: 800,
80
+ fontSize: '13px',
81
+ letterSpacing: '-0.02em',
82
+ color: theme.colors.gray[9],
83
+ },
84
+ description: {
85
+ fontSize: '13px',
86
+ color: theme.colors.gray[6],
87
+ },
88
+ }),
89
+ },
90
+ }
91
+
92
+ /**
93
+ * With no title, Mantine treats the message as a description and paints it with
94
+ * `--mantine-color-black` — the pure black that the system ramp does not
95
+ * contain. It is an attribute selector, which inline `styles` cannot reach; that
96
+ * is why it ships as a string, for the app to paste into its global CSS.
97
+ *
98
+ * The weight does NOT go up to 600: the message is usually a whole sentence, and
99
+ * a whole sentence in bold shouts.
100
+ */
101
+ export const NOTIFY_DESCRIPTION_CSS = `.rlg-notification-description:not([data-with-title]) {
102
+ color: var(--mantine-color-gray-9);
103
+ }`
@@ -0,0 +1,65 @@
1
+ import { notifications } from '@mantine/notifications'
2
+ import { IconAlertCircle, IconCheck, IconInfoCircle } from '@tabler/icons-react'
3
+ import { createElement } from 'react'
4
+
5
+ /**
6
+ * State feedback, in the only pair of colors the system has.
7
+ *
8
+ * The spec forbids color outside the gray ramp — there is no green. But Law 5
9
+ * requires error and success to be distinguishable WITHOUT READING. The way out
10
+ * isn't to invent a color: it's to use the two dimensions that remain, color AND
11
+ * glyph.
12
+ *
13
+ * success teal + IconCheck (a confirmation mark)
14
+ * error red + IconAlertCircle (the system's chromatic exception)
15
+ * info gray + IconInfoCircle (neutral, asks for no action)
16
+ *
17
+ * THE GLYPH IS WHITE, AND THE DISC CARRIES THE STATE COLOR. That pairing is the
18
+ * whole point, and the two halves live in different files: the filled disc comes
19
+ * from `notify-theme.js` (which deliberately does NOT touch `backgroundColor`,
20
+ * so Mantine paints it with the state color), and the white glyph comes from
21
+ * here.
22
+ *
23
+ * An earlier version passed the SAME color to the icon's disc (the `color` prop,
24
+ * which becomes --notification-color) and to the glyph drawn inside it — success
25
+ * came out #11181C on #141414 (1.03:1) and error, #E03131 on #E03131 (1.00:1).
26
+ * The two states that matter most arrived as an empty circle: the
27
+ * distinction-without-reading that Law 5 requires simply did not exist.
28
+ *
29
+ * So the two files must move together. Painting the glyph the state color here,
30
+ * or forcing `backgroundColor: transparent` there, brings the ~1:1 contrast
31
+ * straight back.
32
+ *
33
+ * The value of `color` also can't be 'dark': that ramp doesn't exist in
34
+ * theme.colors and falls back to Mantine's stock palette (#141414), exactly what
35
+ * this file was written to eliminate.
36
+ */
37
+ const icon = Component => createElement(Component, { size: 18, stroke: 2, color: 'var(--mantine-color-white)' })
38
+
39
+ /**
40
+ * Half the calls pass only one argument (`notify.info('Copiado')`). That text is
41
+ * the MESSAGE, not the title: sending it as `title` leaves the description
42
+ * empty, and Mantine then paints the text with the `:not([data-with-title])`
43
+ * rule — `--mantine-color-black`, a pure black the system's ramp does not
44
+ * contain (gray.9 is #11181C precisely to avoid it).
45
+ */
46
+ const show = (color, glyph, title, message) =>
47
+ notifications.show({
48
+ ...(message === undefined ? { message: title } : { title, message }),
49
+ color,
50
+ icon: glyph,
51
+ })
52
+
53
+ export const notify = {
54
+ success(title, message) {
55
+ show('teal', icon(IconCheck), title, message)
56
+ },
57
+
58
+ error(title, message) {
59
+ show('red', icon(IconAlertCircle), title, message)
60
+ },
61
+
62
+ info(title, message) {
63
+ show('gray', icon(IconInfoCircle), title, message)
64
+ },
65
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The table's geometry — values from spec §Table.
3
+ *
4
+ * It lives outside DataTable.jsx so that file exports only components (a Fast
5
+ * Refresh requirement) and, above all, so the skeleton derives the grid from the
6
+ * SAME source as the table: the two have no way to diverge if they read the same
7
+ * expression.
8
+ */
9
+ export const ROW_PADDING = '14px var(--rlg-table-px, 24px)'
10
+ export const HEAD_PADDING = '12px var(--rlg-table-px, 24px)'
11
+
12
+ /**
13
+ * The column widths become the gridTemplateColumns string.
14
+ *
15
+ * Each flexible track becomes `minmax(0, Nfr)`: without the zero minimum, an
16
+ * `fr` track never shrinks below its own content (a <Code> or a date won't
17
+ * wrap), and the grid overflows the Card's width — which has overflow hidden and
18
+ * cuts off the actions column. Fixed widths (`44px`) pass through untouched.
19
+ */
20
+ export function templateFor(columns) {
21
+ return columns
22
+ .map(c => {
23
+ const width = c.width || '1fr'
24
+ return width.endsWith('fr') ? `minmax(0, ${width})` : width
25
+ })
26
+ .join(' ')
27
+ }
@@ -0,0 +1,49 @@
1
+ import { useCallback, useMemo, useState } from 'react'
2
+
3
+ /**
4
+ * Um modal de cada vez.
5
+ *
6
+ * VEIO DE SETE CÓPIAS, TRÊS DELAS BYTE-IDÊNTICAS.
7
+ *
8
+ * A única diferença de CÓDIGO entre as sete era `export function` (cinco) vs
9
+ * `export default function` (Storage e Auth) — o resto era o mesmo comentário
10
+ * traduzido. O do Monitors chegava a dizer, sobre a própria cópia: "copiado sem
11
+ * mudança dos painéis do Hoster, Payments e Messages — se o controlador de
12
+ * modal aqui divergisse do deles, o sistema teria dois".
13
+ *
14
+ * Cada modal como seu próprio booleano (`docsOpened`, `supportOpened`, …) deixa
15
+ * a exclusão por conta de quem escreve a próxima tela: basta abrir um sem
16
+ * fechar o outro e os dois empilham, com o de baixo aparecendo pelas beiradas
17
+ * do de cima. Aconteceu com Docs e Suporte — dois cartões sobrepostos, dois
18
+ * overlays somando escuro, e o Esc fechando só o de cima.
19
+ *
20
+ * Aqui o estado não é "quais estão abertos", é "qual está aberto": um `id` ou
21
+ * `null`. Abrir o segundo substitui o primeiro em vez de somar, porque não há
22
+ * onde somar. A regra vale por construção, não por disciplina.
23
+ *
24
+ * @returns {{
25
+ * current: string | null,
26
+ * isOpen: (id: string) => boolean,
27
+ * open: (id: string) => void,
28
+ * close: () => void,
29
+ * modalProps: (id: string) => { opened: boolean, onClose: () => void },
30
+ * }}
31
+ */
32
+ export function useExclusiveModal() {
33
+ const [current, setCurrent] = useState(null)
34
+
35
+ const open = useCallback(id => setCurrent(id), [])
36
+ const close = useCallback(() => setCurrent(null), [])
37
+ const isOpen = useCallback(id => current === id, [current])
38
+
39
+ /**
40
+ * O par `opened`/`onClose` que todo Modal do Mantine pede.
41
+ *
42
+ * Espalhar isto (`{...modalProps('docs')}`) em vez de escrever os dois à
43
+ * mão tira o descuido mais fácil de cometer: `onClose` que zera o booleano
44
+ * errado, ou que esquece de zerar.
45
+ */
46
+ const modalProps = useCallback(id => ({ opened: current === id, onClose: close }), [current, close])
47
+
48
+ return useMemo(() => ({ current, isOpen, open, close, modalProps }), [current, isOpen, open, close, modalProps])
49
+ }