@ciromaciel/dashboard-kit 1.4.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciromaciel/dashboard-kit",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "The components every dashboard shares: table, empty state, loading state, destructive confirm, danger zone and wordmark.",
@@ -52,7 +52,7 @@ export function ActiveResource({ title, subtitle, tone = 'muted', mono = false }
52
52
 
53
53
  {subtitle && (
54
54
  <Text
55
- fz={9}
55
+ fz={10}
56
56
  fw={800}
57
57
  c={tone === 'alert' ? 'red.6' : 'gray.4'}
58
58
  tt="uppercase"
@@ -25,7 +25,7 @@ import { templateFor, ROW_PADDING, HEAD_PADDING } from './table-grid'
25
25
  * never injects the table's structure — it only says what each cell shows.
26
26
  */
27
27
 
28
- const HEAD = { fz: 10, fw: 800, c: 'gray.5', tt: 'uppercase', ls: '1.5px', lh: 1.4 }
28
+ const HEAD = { fz: 10, fw: 800, c: 'gray.5', tt: 'uppercase', lts: '1.5px', lh: 1.4 }
29
29
 
30
30
  export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected, quietSelection, getRowLabel, toolbar, footer, emptyState, loading, skeleton }) {
31
31
  const clickable = typeof onRowClick === 'function'
@@ -162,7 +162,7 @@ export function DeleteModal({
162
162
  fw={800}
163
163
  c="red.6"
164
164
  tt="uppercase"
165
- ls="1.5px"
165
+ lts="1.5px"
166
166
  >
167
167
  {eyebrow}
168
168
  </Text>
@@ -182,7 +182,7 @@ export function DeleteModal({
182
182
  fz={scale.fz}
183
183
  lh={1.15}
184
184
  mt={12}
185
- ls="-0.04em"
185
+ lts="-0.04em"
186
186
  c="gray.9"
187
187
  /*
188
188
  * `break-all` only where the title IS the identifier. On the
@@ -23,7 +23,7 @@ export function EmptyState({ title, description, actionLabel, onAction }) {
23
23
  fw={800}
24
24
  c="gray.4"
25
25
  tt="uppercase"
26
- ls="1.5px"
26
+ lts="1.5px"
27
27
  ta="center"
28
28
  >
29
29
  {title}
@@ -138,8 +138,13 @@ export function HoldToConfirmButton({ children, holdingLabel, onConfirm, disable
138
138
  {...props}
139
139
  >
140
140
  {active ? (
141
+ /* The countdown inherits the button's colour. It was gray.6,
142
+ which on the filled red.6 variant measured about 1.3:1 —
143
+ the number that says how long is left was unreadable.
144
+ Explicit `inherit`, because the panel themes give Text a
145
+ default colour that would otherwise win. */
141
146
  <Text
142
- c="gray.6"
147
+ c="inherit"
143
148
  span
144
149
  inherit
145
150
  >
@@ -110,7 +110,7 @@ export function Metric({ label, value, unit, note, hint, delta, deltaSuffix = '%
110
110
  fw={900}
111
111
  c="gray.9"
112
112
  lh={1}
113
- ls="-0.04em"
113
+ lts="-0.04em"
114
114
  style={{ overflowWrap: 'anywhere' }}
115
115
  >
116
116
  {value ?? '—'}
@@ -134,7 +134,7 @@ export function Metric({ label, value, unit, note, hint, delta, deltaSuffix = '%
134
134
  fw={800}
135
135
  c="gray.4"
136
136
  tt="uppercase"
137
- ls="1.5px"
137
+ lts="1.5px"
138
138
  lh={1.4}
139
139
  mt={12}
140
140
  >
@@ -77,7 +77,7 @@ export function MoreResults({ loading, hasMore, onLoadMore, shown, total, noun =
77
77
  fw={800}
78
78
  c="gray.4"
79
79
  tt="uppercase"
80
- ls="1.5px"
80
+ lts="1.5px"
81
81
  >
82
82
  {total} {total === 1 ? noun : plural} · fim da lista
83
83
  </Text>
@@ -103,7 +103,7 @@ export function MoreResults({ loading, hasMore, onLoadMore, shown, total, noun =
103
103
  fw={800}
104
104
  c="gray.4"
105
105
  tt="uppercase"
106
- ls="1.5px"
106
+ lts="1.5px"
107
107
  /* `aria-live` because a scroll-triggered load has no
108
108
  focus: without this, a screen reader would not know
109
109
  the list had grown. */
@@ -127,7 +127,7 @@ export function MoreResults({ loading, hasMore, onLoadMore, shown, total, noun =
127
127
  fw={800}
128
128
  c="gray.4"
129
129
  tt="uppercase"
130
- ls="1.5px"
130
+ lts="1.5px"
131
131
  >
132
132
  {shown} de {total}
133
133
  </Text>
@@ -64,7 +64,7 @@ export function PageHeader({ crumb, title, subtitle, actions, order = 1, mb = 32
64
64
  fw={800}
65
65
  c="gray.4"
66
66
  tt="uppercase"
67
- ls="1.5px"
67
+ lts="1.5px"
68
68
  lh={1.4}
69
69
  >
70
70
  {trilha}
@@ -91,7 +91,7 @@ export function PageHeader({ crumb, title, subtitle, actions, order = 1, mb = 32
91
91
  */
92
92
  fw={900}
93
93
  lh={1}
94
- ls="-0.04em"
94
+ lts="-0.04em"
95
95
  c="gray.9"
96
96
  style={{ overflowWrap: 'anywhere' }}
97
97
  >
@@ -75,7 +75,7 @@ export function ResourceItem({ icon: Icon, title, subtitle, tone = 'muted', mono
75
75
 
76
76
  {subtitle && (
77
77
  <Text
78
- fz={9}
78
+ fz={10}
79
79
  fw={800}
80
80
  c={tone === 'alert' ? 'red.6' : 'gray.4'}
81
81
  tt="uppercase"
@@ -56,7 +56,7 @@ export function SectionCard({ eyebrow, title, lead, action, children }) {
56
56
  c="gray.4"
57
57
  tt="uppercase"
58
58
  mb={6}
59
- ls="1.5px"
59
+ lts="1.5px"
60
60
  >
61
61
  {eyebrow}
62
62
  </Text>
@@ -66,7 +66,7 @@ export function SectionCard({ eyebrow, title, lead, action, children }) {
66
66
  fz={18}
67
67
  fw={800}
68
68
  lh={1.2}
69
- ls="-0.02em"
69
+ lts="-0.02em"
70
70
  c="gray.9"
71
71
  >
72
72
  {title}
@@ -81,14 +81,13 @@ export function useHealthCheck(enabled, apiUrl) {
81
81
  /**
82
82
  * The status light.
83
83
  *
84
- * `--rlg-status-up` is the system's only green, and it exists because this is
85
- * NOT the success of an action (that is gray.9 + IconCheck): it is the measured
86
- * state of an external system, and it never appears without the word next to
87
- * it.
84
+ * Green is the system's success colour (`--rlg-success`), the same one the
85
+ * notification and the status bars use. It never appears without the word
86
+ * next to it.
88
87
  */
89
88
  export function HealthRow({ health, host }) {
90
89
  const label = health.status === 'up' ? 'No ar' : health.status === 'down' ? 'Sem resposta' : 'Verificando…'
91
- const color = health.status === 'up' ? 'var(--rlg-status-up)' : health.status === 'down' ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-gray-4)'
90
+ const color = health.status === 'up' ? 'var(--rlg-success)' : health.status === 'down' ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-gray-4)'
92
91
 
93
92
  return (
94
93
  <Group
@@ -111,7 +110,7 @@ export function HealthRow({ health, host }) {
111
110
  />
112
111
  <Text
113
112
  fz={12}
114
- fw={600}
113
+ fw={500}
115
114
  c="gray.9"
116
115
  lineClamp={1}
117
116
  >
@@ -198,7 +197,7 @@ export function SupportModal({
198
197
  fz={{ base: 26, sm: 36 }}
199
198
  lh={1.05}
200
199
  mt={12}
201
- ls="-0.04em"
200
+ lts="-0.04em"
202
201
  c="gray.9"
203
202
  >
204
203
  {title}{' '}
@@ -124,7 +124,7 @@ export function TableToolbar({ value = '', onChange, placeholder = 'Buscar…',
124
124
  fw={800}
125
125
  c="gray.4"
126
126
  tt="uppercase"
127
- ls="1.5px"
127
+ lts="1.5px"
128
128
  style={{ flexShrink: 0 }}
129
129
  visibleFrom="sm"
130
130
  >
@@ -40,7 +40,7 @@ export function Wordmark({ name, variant = 'mark', size, ...props }) {
40
40
  fz={size ?? (isLabel ? 11 : 28)}
41
41
  fw={isLabel ? 800 : 900}
42
42
  tt={isLabel ? 'uppercase' : undefined}
43
- ls={isLabel ? '1.5px' : '-0.04em'}
43
+ lts={isLabel ? '1.5px' : '-0.04em'}
44
44
  lh={isLabel ? 1.4 : 1}
45
45
  c={isLabel ? 'gray.4' : 'gray.9'}
46
46
  ta="center"
@@ -19,7 +19,7 @@ export function ZenLabel({ children, ...props }) {
19
19
  fz={11}
20
20
  fw={800}
21
21
  tt="uppercase"
22
- ls="1.5px"
22
+ lts="1.5px"
23
23
  c="gray.4"
24
24
  lh={1.4}
25
25
  {...props}
@@ -10,7 +10,7 @@ import { createElement } from 'react'
10
10
  * isn't to invent a color: it's to use the two dimensions that remain, color AND
11
11
  * glyph.
12
12
  *
13
- * success teal + IconCheck (a confirmation mark)
13
+ * success green.6 + IconCheck (a confirmation mark)
14
14
  * error red + IconAlertCircle (the system's chromatic exception)
15
15
  * info gray + IconInfoCircle (neutral, asks for no action)
16
16
  *
@@ -43,20 +43,31 @@ const icon = Component => createElement(Component, { size: 18, stroke: 2, color:
43
43
  * rule — `--mantine-color-black`, a pure black the system's ramp does not
44
44
  * contain (gray.9 is #11181C precisely to avoid it).
45
45
  */
46
- const show = (color, glyph, title, message) =>
46
+ const show = (color, glyph, title, message, options) =>
47
47
  notifications.show({
48
48
  ...(message === undefined ? { message: title } : { title, message }),
49
49
  color,
50
50
  icon: glyph,
51
+ ...options,
51
52
  })
52
53
 
54
+ /**
55
+ * An error stays until it is dismissed.
56
+ *
57
+ * Apple's rule is to prefer explicit dismissal over views that vanish on a
58
+ * timer, and WCAG 2.2.1 says the same: an error read in five seconds is an
59
+ * error missed by whoever looked away. Success and info still leave on their
60
+ * own — they ask for nothing.
61
+ */
62
+ const STICKY = { autoClose: false }
63
+
53
64
  export const notify = {
54
65
  success(title, message) {
55
- show('teal', icon(IconCheck), title, message)
66
+ show('green.6', icon(IconCheck), title, message)
56
67
  },
57
68
 
58
69
  error(title, message) {
59
- show('red', icon(IconAlertCircle), title, message)
70
+ show('red', icon(IconAlertCircle), title, message, STICKY)
60
71
  },
61
72
 
62
73
  info(title, message) {
package/src/index.js CHANGED
@@ -64,6 +64,7 @@ export { DangerZone } from './components/DangerZone'
64
64
  export { ZenLabel } from './components/ZenLabel'
65
65
  export { notify } from './components/notify'
66
66
  export { NOTIFICATIONS_PROPS, NOTIFY_DESCRIPTION_CSS, notificationTheme } from './components/notify-theme'
67
+ export { panelTheme } from './theme'
67
68
  export { Wordmark } from './components/Wordmark'
68
69
  export { SupportModal, HealthRow, useHealthCheck } from './components/SupportModal'
69
70
  export { AuthLoader } from './components/AuthLoader'
package/src/theme.js ADDED
@@ -0,0 +1,443 @@
1
+ import { createTheme, rem } from '@mantine/core'
2
+ import { notificationTheme } from './components/notify-theme'
3
+
4
+ /**
5
+ * The panel theme — one for every dashboard (14/10/2026).
6
+ *
7
+ * Until then each panel carried its own `theme.js`: eighteen files, seventeen of
8
+ * them different, copied from one another at different moments. None held a
9
+ * real exception — the differences were versions that stopped evolving. They
10
+ * all agreed on radius and font and diverged on the rest: buttons in two case
11
+ * styles, h2 bigger than h1, inputs at three sizes.
12
+ *
13
+ * A panel now does `export { panelTheme as theme } from '@ciromaciel/dashboard-kit'`,
14
+ * and an exception that is truly the product's own goes through
15
+ * `mergeThemeOverrides` with a comment saying why.
16
+ *
17
+ * The values come from the Zen design system (ciromaciel.click/design-system),
18
+ * at the PANEL density: data at 12-14px, screen title at 36px. The ramp is the
19
+ * same as the components-kit's; the two files must move together.
20
+ */
21
+ export const panelTheme = createTheme({
22
+ fontFamily: 'Montserrat, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
23
+ fontFamilyMonospace: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
24
+
25
+ /**
26
+ * Radius 0 is an invariant, not a preference. A rounded corner is pure
27
+ * chrome — it communicates nothing, it only softens, and softening is the
28
+ * opposite of what massive hierarchy needs.
29
+ */
30
+ defaultRadius: 0,
31
+
32
+ /**
33
+ * h1 is 36px because in a panel the title shares the line with the actions
34
+ * and the width is controlled — it does not need to scale like a landing
35
+ * page's.
36
+ *
37
+ * Allowed tracking: -0.04em (display), 1px (control), 1.5px and 2px (label).
38
+ * Display needs to regroup; a label needs to breathe.
39
+ */
40
+ /**
41
+ * A ladder, each step clearly below the one above. Until 14/10/2026 every
42
+ * panel shipped h2 at 48px and h3 at 32px — both BIGGER than the 36px h1,
43
+ * the opposite of Law 2. It did not show because 29 of the 30 `order={2}`
44
+ * calls passed `fz` by hand; the one that trusted the theme got 48px.
45
+ */
46
+ headings: {
47
+ fontFamily: 'Montserrat, sans-serif',
48
+ fontWeight: '900',
49
+ sizes: {
50
+ h1: { fontSize: rem(36), lineHeight: '1', letterSpacing: '-0.04em' },
51
+ h2: { fontSize: rem(24), lineHeight: '1.1', letterSpacing: '-0.04em' },
52
+ h3: { fontSize: rem(18), lineHeight: '1.2', letterSpacing: '-0.02em', fontWeight: '800' },
53
+ h4: { fontSize: rem(15), lineHeight: '1.3', letterSpacing: '-0.02em', fontWeight: '800' },
54
+ h5: { fontSize: rem(13), lineHeight: '1.3', letterSpacing: '-0.02em', fontWeight: '800' },
55
+ },
56
+ },
57
+
58
+ /**
59
+ * Mantine's `--mantine-color-text` is `theme.black`, and no panel set it:
60
+ * every input, table cell and unstyled text came out #000, a black the ramp
61
+ * does not contain. gray.9 is the system's black.
62
+ */
63
+ black: '#11181C',
64
+
65
+ colors: {
66
+ /**
67
+ * The ramp. Steps 0-3 are surface and structure; 4-9 are ink. None of
68
+ * them crosses the boundary.
69
+ *
70
+ * gray.9 (#11181C) is not #000: pure black on a large screen vibrates and
71
+ * tires the eye. gray.4 fails AA for body text (2.6:1) — which is why it
72
+ * only lives in uppercase labels.
73
+ */
74
+ gray: [
75
+ '#FFFFFF', // 0 · App base
76
+ '#F9FAFB', // 1 · Alternate surface — table header, hover
77
+ '#F3F4F6', // 2 · Default hairline — EVERY border in the system
78
+ '#E5E7EB', // 3 · Mute border — quotation, disabled
79
+ '#9CA3AF', // 4 · Uppercase label ONLY
80
+ '#6B7280', // 5 · Labels, captions
81
+ '#4B5563', // 6 · Body — the most used color
82
+ '#374151', // 7 · Emphatic body, tabular data
83
+ '#1F2937', // 8 · Rare
84
+ '#11181C', // 9 · RiLiGar black — titles, metrics, filled button
85
+ ],
86
+
87
+ /**
88
+ * The system's chromatic exception. The semantics of LOSS and of error —
89
+ * never decoration, never the same color as success (Law 5).
90
+ *
91
+ * Success is green (below). The distinction is colour AND glyph,
92
+ * legible without reading the text.
93
+ */
94
+ /**
95
+ * Success — the measured "it worked" (14/10/2026). Green, not teal: it
96
+ * is the green "No ar" already used in every panel, and it separates
97
+ * from red better for red-green colour blindness. green.6 fills (dot,
98
+ * disc, bar); green.7 is the ink for text on white (5.0:1). Like red,
99
+ * it never appears without the word or the glyph beside it.
100
+ */
101
+ green: ['#F0FDF4', '#DCFCE7', '#BBF7D0', '#86EFAC', '#4ADE80', '#22C55E', '#16A34A', '#15803D', '#166534', '#14532D'],
102
+
103
+ red: ['#FFF5F5', '#FFE3E3', '#FFC9C9', '#FFA8A8', '#FF8787', '#FF6B6B', '#E03131', '#C92A2A', '#B02525', '#962020'],
104
+ },
105
+
106
+ primaryColor: 'gray',
107
+ primaryShade: 9,
108
+ autoContrast: true,
109
+
110
+ // Zen: shadows exist only for real elevation (Modal, Popover, Menu).
111
+ // Surfaces use a 1px gray.2 border — never a shadow.
112
+ shadows: {
113
+ xs: 'none',
114
+ sm: 'none',
115
+ md: '0 4px 20px rgba(0,0,0,0.05)',
116
+ lg: '0 10px 30px rgba(0,0,0,0.06)',
117
+ xl: '0 20px 40px rgba(0,0,0,0.08)',
118
+ },
119
+
120
+ other: {
121
+ // Small Caps Label — the "announcement" of the canonical pair.
122
+ zenLabel: { fz: 11, fw: 800, tt: 'uppercase', lts: '1.5px', c: 'gray.4' },
123
+ // Panel geometry (spec §Panel geometry).
124
+ panel: { sidebar: 300, sidebarCollapsed: 64, px: 48, pt: 40, pb: 80, maw: 1280, headerMb: 32, blockGap: 20 },
125
+ // Destructive action through persistence, not through a dialog.
126
+ hold: { holdMs: 3000, tickMs: 50 },
127
+ },
128
+
129
+ components: {
130
+ // --- SURFACES ---
131
+ /**
132
+ * Depth comes from surface contrast and from the 1px hairline — never
133
+ * from a shadow.
134
+ *
135
+ * Paper = a declared surface (the block needs to stand out from the
136
+ * stage).
137
+ * Card = an invisible grouping (the space is already enough). When in
138
+ * doubt, Card.
139
+ */
140
+ Paper: {
141
+ defaultProps: { withBorder: true, shadow: 'none', radius: '0' },
142
+ vars: () => ({ root: { '--paper-border-color': 'var(--mantine-color-gray-2)' } }),
143
+ },
144
+ Card: {
145
+ defaultProps: { withBorder: true, shadow: 'none', padding: 'lg' },
146
+ vars: () => ({ root: { '--paper-border-color': 'var(--mantine-color-gray-2)' } }),
147
+ },
148
+ Modal: {
149
+ defaultProps: {
150
+ centered: true,
151
+ size: 'md',
152
+ padding: 'xl',
153
+ radius: 0,
154
+ overlayProps: { backgroundOpacity: 0.45 }, // no blur: glassmorphism is chrome
155
+ },
156
+ styles: {
157
+ title: { fontWeight: 800, fontSize: rem(16), letterSpacing: '-0.04em' },
158
+ },
159
+ },
160
+ Menu: {
161
+ defaultProps: { shadow: 'none', width: 210, withinPortal: true, radius: 0 },
162
+ styles: theme => ({
163
+ dropdown: { border: `1px solid ${theme.colors.gray[2]}` },
164
+ item: { fontSize: rem(13), fontWeight: 500 },
165
+ label: { fontSize: rem(10), fontWeight: 800, textTransform: 'uppercase', letterSpacing: '1.5px' },
166
+ }),
167
+ },
168
+ Popover: {
169
+ defaultProps: { shadow: 'none', radius: 0 },
170
+ styles: theme => ({ dropdown: { border: `1px solid ${theme.colors.gray[2]}` } }),
171
+ },
172
+
173
+ // --- DATA ---
174
+ /**
175
+ * The "data" role: 12-14px, gray.7. A 16px paragraph is comfortable; a
176
+ * 200-row table at 16px is impossible to scan.
177
+ *
178
+ * The app's main table is CSS Grid (see components/zen/DataTable) — fr
179
+ * for content, fixed px for actions, the whole row clickable. These
180
+ * defaults apply to the simple tables that are left.
181
+ */
182
+ Table: {
183
+ defaultProps: { verticalSpacing: 'sm', horizontalSpacing: 'md', withRowBorders: true },
184
+ styles: theme => ({
185
+ th: {
186
+ fontSize: rem(10),
187
+ fontWeight: 800,
188
+ textTransform: 'uppercase',
189
+ letterSpacing: '1.5px',
190
+ color: theme.colors.gray[5],
191
+ borderBottom: `1px solid ${theme.colors.gray[2]}`,
192
+ whiteSpace: 'nowrap',
193
+ },
194
+ td: { borderBottom: `1px solid ${theme.colors.gray[2]}`, fontSize: rem(13), color: theme.colors.gray[7] },
195
+ }),
196
+ },
197
+ Badge: {
198
+ defaultProps: { size: 'sm', variant: 'outline', color: 'gray', fw: 800, radius: 0 },
199
+ styles: { label: { textTransform: 'uppercase', letterSpacing: '1.5px', fontSize: rem(10) } },
200
+ },
201
+ Code: {
202
+ styles: { root: { fontSize: rem(12) } },
203
+ },
204
+ Skeleton: {
205
+ // The Skeleton reproduces the content's real grid — and square
206
+ // corners, like everything else.
207
+ defaultProps: { radius: 0 },
208
+ },
209
+
210
+ // --- CONTROLS ---
211
+ /**
212
+ * Hierarchy without color — filled (primary) → outline (secondary) →
213
+ * subtle (tertiary).
214
+ */
215
+ /*
216
+ * This panel follows the design system here where the theme inherited from
217
+ * Analytics did not: a button is a LABEL in a rectangle — uppercase,
218
+ * 800, 1px tracking (02-tipografia), as the prototype draws it.
219
+ */
220
+ Button: {
221
+ defaultProps: { size: 'sm', radius: '0' },
222
+ styles: { root: { textTransform: 'uppercase', fontWeight: 800, letterSpacing: '1px', fontSize: rem(12) } },
223
+ },
224
+ /**
225
+ * `loaderProps` exists because `bars` and `oval` do not occupy the same
226
+ * space with the same number. The ActionIcon sizes its loader at
227
+ * `calc(var(--ai-size) * 0.55)` — ~15px at the `md` used here. For the
228
+ * `oval` that is a thin-stroked ring; for the `bars` it is a FULL 15px
229
+ * block, three bars painted from end to end.
230
+ *
231
+ * Next to 16px glyphs with `stroke={1.5}`, the two numbers are equal and
232
+ * the two visual masses are not: the loader became the heaviest element
233
+ * of the row, and the whole row seemed to flicker in size when an audit
234
+ * started.
235
+ *
236
+ * 12px gives the loader back the mass of an outline icon. Here and not
237
+ * in the screen because the mismatch comes from the geometry of `bars`
238
+ * with the `ActionIcon` — it applies to every icon button in the panel,
239
+ * and defined case by case it diverges again on the next one.
240
+ */
241
+ ActionIcon: {
242
+ defaultProps: { size: 'md', variant: 'subtle', color: 'gray', radius: 0, loaderProps: { size: 12 } },
243
+ },
244
+ /**
245
+ * The input's frame is the gray.2 hairline at rest and gray.9 on focus.
246
+ * The `unstyled` variant (used on the stage) is left out: there only the
247
+ * bottom border exists.
248
+ *
249
+ * THE DESCRIPTION GOES BELOW THE FIELD, not between the label and it.
250
+ *
251
+ * Mantine's default order (label → description → input) misaligns any
252
+ * row of side-by-side fields: a two-line description pushes its own
253
+ * input 34px down, and the neighbor without a description is left
254
+ * hanging at the top. That is what happened with "Cor de marca / Fonte /
255
+ * Tema" on the status page screen — three inputs at three heights.
256
+ *
257
+ * Below the field, the top of every single-line input coincides, and the
258
+ * alignment stops depending on how many lines each text takes up. The
259
+ * reading also improves: the label says what it is, the field shows the
260
+ * value, and the note explains — in the order in which one reads and
261
+ * fills it in.
262
+ *
263
+ * It applies to the whole panel, not field by field: defined here, it is
264
+ * invariant; done in the screen, it diverges again on the next form.
265
+ */
266
+ TextInput: {
267
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
268
+ styles: (theme, props) => ({
269
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
270
+ input: props.variant === 'unstyled' ? {} : { border: `1px solid ${theme.colors.gray[2]}` },
271
+ }),
272
+ },
273
+ /**
274
+ * The PasswordInput was left out when this block was written, and so it
275
+ * fell back to Mantine's default (`size: "md"`): on the sign-in screen,
276
+ * the password field was visibly larger than the e-mail one right above
277
+ * it — font, height and rounded corners, all different.
278
+ *
279
+ * It is not a simple input: it carries the eye button
280
+ * (`visibilityToggle`) and that is why it has its own component in the
281
+ * theme. Inheriting from `TextInput` does not happen — in Mantine each
282
+ * component reads its own key.
283
+ */
284
+ PasswordInput: {
285
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
286
+ styles: (theme, props) => ({
287
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
288
+ input: props.variant === 'unstyled' ? {} : { border: `1px solid ${theme.colors.gray[2]}` },
289
+ }),
290
+ },
291
+ Textarea: {
292
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
293
+ styles: (theme, props) => ({
294
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
295
+ input: props.variant === 'unstyled' ? {} : { border: `1px solid ${theme.colors.gray[2]}` },
296
+ }),
297
+ },
298
+ Select: {
299
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
300
+ styles: (theme, props) => ({
301
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
302
+ input: props.variant === 'unstyled' ? {} : { border: `1px solid ${theme.colors.gray[2]}` },
303
+ }),
304
+ },
305
+ NumberInput: {
306
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
307
+ styles: theme => ({
308
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
309
+ input: { border: `1px solid ${theme.colors.gray[2]}` },
310
+ }),
311
+ },
312
+ Autocomplete: {
313
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
314
+ styles: theme => ({
315
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
316
+ input: { border: `1px solid ${theme.colors.gray[2]}` },
317
+ }),
318
+ },
319
+ /**
320
+ * The TagsInput is a PILL BOX, not a simple input — and that is why it
321
+ * misaligned with the TextInput next to it.
322
+ *
323
+ * Mantine sizes that box by its content: empty, it measured 22px against
324
+ * the TextInput's 36px, and it also got larger internal breathing room.
325
+ * On a "Nome | Tags" row the two fields started with 6px of difference —
326
+ * too little to name, enough to make the row look crooked.
327
+ *
328
+ * The target of `minHeight` is the FRAME (`.mantine-PillsInput-root`),
329
+ * not the `input`: in this component `input` names the typing field
330
+ * INSIDE the pill box, and sizing it does not move the frame. That is
331
+ * what made the first attempt fix nothing.
332
+ *
333
+ * The box keeps growing as the tags come in, which is the right behavior
334
+ * — the minimum only guarantees the alignment at rest.
335
+ */
336
+ TagsInput: {
337
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
338
+ styles: theme => ({
339
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
340
+ input: {
341
+ border: `1px solid ${theme.colors.gray[2]}`,
342
+ minHeight: rem(36),
343
+ display: 'flex',
344
+ alignItems: 'center',
345
+ paddingTop: 0,
346
+ paddingBottom: 0,
347
+ },
348
+ }),
349
+ },
350
+ ColorInput: {
351
+ defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
352
+ styles: theme => ({
353
+ label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
354
+ input: { border: `1px solid ${theme.colors.gray[2]}` },
355
+ }),
356
+ },
357
+ SegmentedControl: {
358
+ defaultProps: { size: 'xs', radius: 0, color: 'gray.9' },
359
+ styles: { label: { fontWeight: 700, fontSize: rem(12) } },
360
+ },
361
+ Switch: {
362
+ defaultProps: { size: 'sm', color: 'gray.9', radius: 0 },
363
+ styles: { label: { fontSize: rem(13), fontWeight: 500 } },
364
+ },
365
+ Checkbox: {
366
+ defaultProps: { size: 'sm', radius: 0, color: 'gray.9' },
367
+ },
368
+ // The prototype's tabs: 11px labels, uppercase, 800, 1px tracking.
369
+ Tabs: {
370
+ styles: { tab: { fontSize: rem(11), fontWeight: 800, textTransform: 'uppercase', letterSpacing: '1px', borderRadius: 0 } },
371
+ },
372
+ Kbd: {
373
+ defaultProps: { size: 'xs' },
374
+ styles: theme => ({
375
+ root: {
376
+ fontFamily: 'var(--mantine-font-family-monospace)',
377
+ fontSize: rem(10),
378
+ fontWeight: 700,
379
+ color: theme.colors.gray[5],
380
+ backgroundColor: theme.white,
381
+ border: `1px solid ${theme.colors.gray[2]}`,
382
+ borderBottomColor: theme.colors.gray[2],
383
+ boxShadow: 'none',
384
+ borderRadius: 0,
385
+ },
386
+ }),
387
+ },
388
+ ThemeIcon: {
389
+ // The ThemeIcon is always square, never a circle.
390
+ defaultProps: { size: 'lg', variant: 'light', color: 'gray', radius: 0 },
391
+ },
392
+ Avatar: {
393
+ // The system's portrait is square, like the ThemeIcon above.
394
+ defaultProps: { radius: 0, color: 'gray' },
395
+ },
396
+ Alert: {
397
+ defaultProps: { variant: 'light', color: 'gray', radius: 0 },
398
+ styles: { title: { fontWeight: 800, fontSize: rem(12) }, message: { fontSize: rem(13) } },
399
+ },
400
+ Tooltip: {
401
+ defaultProps: { fz: 'xs', withArrow: false, openDelay: 300, color: 'gray.9', radius: 0 },
402
+ },
403
+ Progress: {
404
+ defaultProps: { size: 'sm', color: 'gray.9', radius: 0 },
405
+ },
406
+ /**
407
+ * `bars` as the system default, and not `oval`.
408
+ *
409
+ * This `type` applies to EVERY loader in the panel — including the one
410
+ * that Button and ActionIcon build on their own when they receive
411
+ * `loading`. And that is exactly where the `oval` failed: a ring
412
+ * spinning inside a 16px button is indistinguishable from a still ring,
413
+ * and the defect showed up side by side on the same table row — the
414
+ * status indicator (which already used `bars`) moving, and the one on the
415
+ * button next to it not.
416
+ *
417
+ * The three bars change HEIGHT at different times: the silhouette
418
+ * changes on every frame, so the movement is legible even at 10px and
419
+ * even at a glance. It is the difference between an equalizer and a
420
+ * spinning disc — both spin, only one communicates.
421
+ */
422
+ Loader: {
423
+ defaultProps: { color: 'gray', type: 'bars' },
424
+ },
425
+ // The notification comes from the kit: one shape across the apps and the SDK.
426
+ ...notificationTheme,
427
+
428
+ // --- TYPOGRAPHY ---
429
+ Title: { defaultProps: { lh: 1, c: 'gray.9' } },
430
+ /**
431
+ * A Text with no colour inherits — and what it inherits from the page is
432
+ * body gray.6 (`--mantine-color-text`, set in tokens.css). Until
433
+ * 14/10/2026 that was #000: a paragraph left without a colour came out
434
+ * darker than the title. A fixed `c: 'gray.6'` here would break
435
+ * inheritance instead — the label inside a black button, the line inside
436
+ * the terminal. Title says gray.9; values and emphasis say their colour.
437
+ */
438
+ Text: { defaultProps: { lh: 1.6 } },
439
+ Anchor: { defaultProps: { underline: 'never', fw: 500, c: 'gray.9' } },
440
+ List: { defaultProps: { spacing: 'xs' } },
441
+ Divider: { defaultProps: { color: 'gray.2' } },
442
+ },
443
+ })
package/src/tokens.css CHANGED
@@ -29,21 +29,18 @@
29
29
  --rlg-terminal-rule: rgba(255, 255, 255, 0.08);
30
30
 
31
31
  /*
32
- * The system's only green, used by the SupportModal's status light.
32
+ * Success — one green for every "it worked" (14/10/2026): the notification
33
+ * disc, the "No ar" light, the status bars, the inline "Salvo". It used to
34
+ * be three (teal in notify, teal.7 in the status bars, this green in the
35
+ * light). `--rlg-success` fills; `--rlg-success-ink` is for text on white.
33
36
  *
34
- * Five panels declared it with this same value; Auth and Storage did not —
35
- * and those are exactly the two that did not yet have the modal. Without
36
- * this floor the "No ar" dot would render WITH NO COLOUR at all in those
37
- * two: a `var()` with no value and no fallback does not apply the property,
38
- * and there is no build error and no console warning. It is the same defect
39
- * as the hairline described above.
40
- *
41
- * It lives outside the gray ramp because this is not the success of an
42
- * ACTION (that is gray.9 + IconCheck): it is the measured state of an
43
- * external system. The rule that keeps the exception honest is that the
44
- * colour never appears without the word beside it.
37
+ * `--rlg-status-up` is the old name, kept so that panels still reading it
38
+ * get the same green. As with red, the colour never appears without the
39
+ * word or the glyph beside it.
45
40
  */
46
- --rlg-status-up: #16a34a;
41
+ --rlg-success: var(--mantine-color-green-6, #16a34a);
42
+ --rlg-success-ink: var(--mantine-color-green-7, #15803d);
43
+ --rlg-status-up: var(--rlg-success);
47
44
  }
48
45
 
49
46
  /*
@@ -56,3 +53,72 @@
56
53
  .rlg-danger:focus-visible {
57
54
  outline-color: var(--mantine-color-red-7);
58
55
  }
56
+
57
+ /*
58
+ * The rules below are the same floor the components-kit ships in its global
59
+ * stylesheet. The panels don't load that sheet, so they are repeated here — the
60
+ * two files must move together.
61
+ */
62
+
63
+ /*
64
+ * One focus ring everywhere: gray.9, 2px, 2px away, square like the element it
65
+ * surrounds. `.rlg-danger` above keeps the shape and changes only the colour.
66
+ */
67
+ :focus-visible {
68
+ outline: 2px solid var(--mantine-color-gray-9);
69
+ outline-offset: 2px;
70
+ }
71
+
72
+ /*
73
+ * 44px under a finger. A panel is dense on purpose — 36px search, 36px create
74
+ * button, `sm` buttons — and that is right for a mouse. A thumb needs Apple's
75
+ * 44px (WCAG 2.5.5), so only coarse pointers get the floor.
76
+ */
77
+ @media (pointer: coarse) {
78
+ .mantine-Button-root,
79
+ .mantine-ActionIcon-root,
80
+ .mantine-CloseButton-root {
81
+ min-height: 44px;
82
+ min-width: 44px;
83
+ }
84
+ }
85
+
86
+ /*
87
+ * Increased contrast moves the ramp one step darker where it is weakest: the
88
+ * gray.4 label (2.6:1) becomes gray.6, gray.5 becomes gray.7, and the gray.2
89
+ * hairline becomes gray.4. `:root:root` beats the `:root` Mantine injects at
90
+ * runtime, after this sheet.
91
+ */
92
+ @media (prefers-contrast: more) {
93
+ :root:root {
94
+ --mantine-color-gray-2: #9ca3af;
95
+ --mantine-color-gray-3: #9ca3af;
96
+ --mantine-color-gray-4: #4b5563;
97
+ --mantine-color-gray-5: #374151;
98
+ --mantine-color-gray-6: #374151;
99
+ }
100
+ }
101
+
102
+ /*
103
+ * Reduced motion: the sidebar appears instead of sliding in. Background and
104
+ * colour fades on rows and menu items stay — a fade is what Apple asks motion
105
+ * to become, not something to remove.
106
+ */
107
+ @media (prefers-reduced-motion: reduce) {
108
+ .mantine-AppShell-navbar,
109
+ .mantine-AppShell-main,
110
+ .mantine-AppShell-header {
111
+ transition: none !important;
112
+ }
113
+ }
114
+
115
+ /*
116
+ * The page's colour is body. `--mantine-color-text` is what every text without
117
+ * a colour of its own inherits — body, a Text without `c`, a table cell, a list
118
+ * item. It was #000 (Mantine's default `black`), darker than the title itself.
119
+ * `:root:root[...]` beats the `:root[...]` Mantine injects after this sheet.
120
+ * Same rule as the components-kit's global.css.
121
+ */
122
+ :root:root[data-mantine-color-scheme='light'] {
123
+ --mantine-color-text: var(--mantine-color-gray-6);
124
+ }