@cerebruminc/cerebellum 20.3.1 → 20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [20.4.0](https://github.com/cerebruminc/cerebellum/compare/v20.3.1...v20.4.0) (2026-09-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **datatable:** style columns from columnDef.meta ([e466fdf](https://github.com/cerebruminc/cerebellum/commit/e466fdf3cdef67a434541f8a2451b26ffae20a22))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **icons:** drop the styled-components dependency from icons ([394fa8e](https://github.com/cerebruminc/cerebellum/commit/394fa8e72c1d7fdce6933f907536b02ba0a416dc))
14
+
3
15
  ## [20.3.1](https://github.com/cerebruminc/cerebellum/compare/v20.3.0...v20.3.1) (2026-09-03)
4
16
 
5
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "20.3.1",
3
+ "version": "20.4.0",
4
4
  "description": "Cerebrum's React Component Library",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file src/components/Icons/IconComponentStyles.module.css
3
+ * @summary The three boxes an icon renders into, plus the svg itself.
4
+ * @remarks Per-instance values (box size, box colour, padding, scale) arrive as inline
5
+ * custom properties rather than generated classes, so the sheet stays static. Each one
6
+ * carries a fallback, so a primitive rendered without it behaves as it did before.
7
+ */
8
+
9
+ /* Unboxed icons: a full-bleed positioning context for the absolutely placed svg. */
10
+ .boringSpan {
11
+ display: block;
12
+ height: 100%;
13
+ position: relative;
14
+ width: 100%;
15
+ }
16
+ .boringSpan svg {
17
+ position: absolute;
18
+ height: 100%;
19
+ width: 100%;
20
+ }
21
+
22
+ /* Boxed and round icons get a fixed outer square instead. */
23
+ .iconBox {
24
+ display: block;
25
+ height: var(--cb-icon-box-size, auto);
26
+ width: var(--cb-icon-box-size, auto);
27
+ }
28
+
29
+ .innerIconBox {
30
+ display: block;
31
+ background-color: var(--cb-icon-box-color, transparent);
32
+ border-radius: 6px;
33
+ height: 100%;
34
+ padding: var(--cb-icon-box-padding, 28.4%);
35
+ width: 100%;
36
+ }
37
+ .innerIconBox[data-round] {
38
+ border-radius: 50%;
39
+ }
40
+
41
+ .svg {
42
+ height: 100%;
43
+ width: 100%;
44
+ transform: scale(var(--cb-icon-scale, 1));
45
+ }
@@ -1,56 +1,39 @@
1
- import { styled } from "styled-components";
1
+ import { CSSProperties, FC, PropsWithChildren, SVGProps } from "react";
2
2
 
3
+ import styles from "./IconComponentStyles.module.css";
3
4
  import { IconBoxProps, InnerIconBoxProps, SvgProps } from "./types";
4
- // ------- Storybook styles ---------
5
- export const IconContainer = styled.div`
6
- display: inline-block;
7
- margin: 30px 20px;
8
- width: 150px;
9
- `;
5
+ // The $-prefixed prop names are a leftover of the styled-components transient-prop
6
+ // convention. They are kept so the 180-odd icon files that pass them need no change.
7
+ export const BoringSpan: FC<PropsWithChildren> = ({ children }) => <span className={styles.boringSpan}>{children}</span>;
10
8
 
11
- export const IconItem = styled.div`
12
- height: 67px;
13
- margin: 10px auto;
14
- width: 67px;
15
- /* background-color: papayawhip; */
16
- `;
9
+ export const IconBox: FC<PropsWithChildren<IconBoxProps>> = ({ $boxedSize, children }) => (
10
+ <span
11
+ className={styles.iconBox}
12
+ style={{ "--cb-icon-box-size": $boxedSize === undefined ? undefined : `${$boxedSize}px` } as CSSProperties}
13
+ >
14
+ {children}
15
+ </span>
16
+ );
17
17
 
18
- export const Name = styled.p`
19
- font-size: 16px;
20
- margin-top: 20px;
21
- text-align: center;
22
- `;
18
+ export const InnerIconBox: FC<PropsWithChildren<InnerIconBoxProps>> = ({ $boxColor, $paddingString, $round, children }) => (
19
+ <span
20
+ className={styles.innerIconBox}
21
+ data-round={$round || undefined}
22
+ style={{ "--cb-icon-box-color": $boxColor, "--cb-icon-box-padding": $paddingString } as CSSProperties}
23
+ >
24
+ {children}
25
+ </span>
26
+ );
23
27
 
24
- // ------- Component styles ---------
25
- export const BoringSpan = styled.span`
26
- display: block;
27
- height: 100%;
28
- position: relative;
29
- width: 100%;
30
- svg {
31
- position: absolute;
32
- height: 100%;
33
- width: 100%;
34
- }
35
- `;
36
-
37
- export const IconBox = styled.span<IconBoxProps>`
38
- display: block;
39
- height: ${({ $boxedSize }) => $boxedSize}px;
40
- width: ${({ $boxedSize }) => $boxedSize}px;
41
- `;
42
-
43
- export const InnerIconBox = styled.span<InnerIconBoxProps>`
44
- display: block;
45
- background-color: ${({ $boxColor }) => $boxColor};
46
- border-radius: ${({ $round }) => ($round ? "50%" : "6px")};
47
- height: 100%;
48
- padding: ${({ $paddingString }) => $paddingString || "28.4%"};
49
- width: 100%;
50
- `;
51
-
52
- export const Svg = styled.svg<SvgProps>`
53
- height: 100%;
54
- width: 100%;
55
- transform: ${(props) => `scale(${props.scale})`};
56
- `;
28
+ // scale is pulled out of the spread so it never lands on the svg as an attribute, where
29
+ // it is not valid; the class reads it back as a custom property instead. className and
30
+ // style are pulled out for the same reason: rest is spread first so nothing can knock
31
+ // them off by accident, and a caller that supplies either one is then taken at its word
32
+ // — className replaces the default styling, and an icon's own transform (BackArrow's
33
+ // scaleX(-1)) outranks the class rule from its inline position.
34
+ export const Svg: FC<SVGProps<SVGSVGElement> & SvgProps> = ({ scale = 1, className, style, children, ...rest }) => (
35
+ // biome-ignore lint/a11y/noSvgWithoutTitle: each icon supplies its own title, or aria-hidden when decorative
36
+ <svg {...rest} className={className ?? styles.svg} style={{ ...style, "--cb-icon-scale": scale } as CSSProperties}>
37
+ {children}
38
+ </svg>
39
+ );
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/components/Icons/IconList.module.css
3
+ * @summary A responsive grid of named icon tiles.
4
+ * @remarks The grid replicates the default rendering of the shared GridContainer,
5
+ * which IconList used before the icons moved off styled-components.
6
+ */
7
+
8
+ .grid {
9
+ display: grid;
10
+ grid-template-columns: repeat(4, 1fr);
11
+ gap: 20px;
12
+ padding: 20px;
13
+ background-color: white;
14
+ }
15
+
16
+ @media all and (max-width: 1000px) {
17
+ .grid {
18
+ grid-template-columns: repeat(2, 1fr);
19
+ }
20
+ }
21
+
22
+ .iconContainer {
23
+ display: inline-block;
24
+ margin: 30px 20px;
25
+ width: 150px;
26
+ }
27
+
28
+ .iconItem {
29
+ height: 67px;
30
+ margin: 10px auto;
31
+ width: 67px;
32
+ }
33
+
34
+ .name {
35
+ font-size: 16px;
36
+ margin-top: 20px;
37
+ text-align: center;
38
+ }
@@ -1,7 +1,6 @@
1
1
  import { FC } from "react";
2
- import { GridContainer } from "./../../SharedStorybook/GridStyles";
3
2
  import { colors } from "./../../const/colors";
4
- import { IconContainer, IconItem, Name } from "./IconComponentStyles";
3
+ import styles from "./IconList.module.css";
5
4
  import { IconListProps } from "./types";
6
5
 
7
6
  import * as iconList from ".";
@@ -25,15 +24,15 @@ export const IconList: FC<IconListProps> = (props: IconListProps) => {
25
24
  let lastIndex = 0;
26
25
 
27
26
  return (
28
- <GridContainer>
27
+ <div className={styles.grid}>
29
28
  {iconNames.map((name) => {
30
29
  const colorIndex = getRandomInt(colorLib.length, lastIndex);
31
30
  lastIndex = colorIndex;
32
31
  // @ts-ignore
33
32
  const IconComponent = iconList[name];
34
33
  return (
35
- <IconContainer key={name}>
36
- <IconItem>
34
+ <div className={styles.iconContainer} key={name}>
35
+ <div className={styles.iconItem}>
37
36
  <IconComponent
38
37
  fill={boxed || round ? colorLib[colorIndex].icon : "#444"}
39
38
  boxed={boxed}
@@ -42,11 +41,11 @@ export const IconList: FC<IconListProps> = (props: IconListProps) => {
42
41
  round={round}
43
42
  title={`${name}: use a descriptive title, or don't include one if the icon is decorative`}
44
43
  />
45
- </IconItem>
46
- <Name>{name}</Name>
47
- </IconContainer>
44
+ </div>
45
+ <p className={styles.name}>{name}</p>
46
+ </div>
48
47
  );
49
48
  })}
50
- </GridContainer>
49
+ </div>
51
50
  );
52
51
  };
@@ -0,0 +1,222 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { render } from "@testing-library/react";
4
+
5
+ import { Accessibility, BackArrow, IconProps, IconType } from ".";
6
+ import * as iconList from ".";
7
+ import { Cerebrum } from "../../assets/icons/Cerebrum";
8
+ import { Cognition } from "../../assets/icons/Cognition";
9
+ import { Cortex } from "../../assets/icons/Cortex";
10
+ import { Neuron } from "../../assets/icons/Neuron";
11
+ import { colors } from "../../const/colors";
12
+ import ResizeIcon from "../Textarea/ResizeIcon";
13
+
14
+ const boxes = (container: HTMLElement) => {
15
+ const outer = container.firstElementChild as HTMLElement;
16
+ const inner = outer.firstElementChild as HTMLElement;
17
+ const svg = inner.firstElementChild as SVGSVGElement;
18
+ return { outer, inner, svg };
19
+ };
20
+
21
+ describe("icon primitives", () => {
22
+ it("renders an unboxed icon as span > span > svg", () => {
23
+ const { outer, inner, svg } = boxes(render(<Accessibility />).container);
24
+
25
+ expect(outer.tagName).toBe("SPAN");
26
+ expect(outer).toHaveClass("boringSpan");
27
+ expect(inner.tagName).toBe("SPAN");
28
+ expect(inner).toHaveClass("boringSpan");
29
+ expect(svg.tagName).toBe("svg");
30
+ expect(svg).toHaveClass("svg");
31
+ });
32
+
33
+ it("sizes and colours a boxed icon through custom properties", () => {
34
+ const { outer, inner } = boxes(render(<Accessibility boxed boxedSize={40} boxColor="#abcdef" />).container);
35
+
36
+ expect(outer).toHaveClass("iconBox");
37
+ expect(outer.style.getPropertyValue("--cb-icon-box-size")).toBe("40px");
38
+ expect(inner).toHaveClass("innerIconBox");
39
+ expect(inner.style.getPropertyValue("--cb-icon-box-color")).toBe("#abcdef");
40
+ expect(inner.style.getPropertyValue("--cb-icon-box-padding")).toBe("25.4%");
41
+ expect(inner).not.toHaveAttribute("data-round");
42
+ });
43
+
44
+ it("falls back to the default box size when none is given", () => {
45
+ const { outer } = boxes(render(<Accessibility boxed />).container);
46
+
47
+ expect(outer.style.getPropertyValue("--cb-icon-box-size")).toBe("67px");
48
+ });
49
+
50
+ it("marks a round icon with data-round and its wider padding", () => {
51
+ const { inner } = boxes(render(<Accessibility round />).container);
52
+
53
+ expect(inner).toHaveAttribute("data-round");
54
+ expect(inner.style.getPropertyValue("--cb-icon-box-padding")).toBe("35%");
55
+ });
56
+
57
+ it("derives the box colour from fill when boxColor is omitted", () => {
58
+ const { inner } = boxes(render(<Accessibility boxed fill={colors.BLUE_100} />).container);
59
+
60
+ expect(inner.style.getPropertyValue("--cb-icon-box-color")).toBe(colors.BLUE_5);
61
+ });
62
+
63
+ it("falls back to black for a fill boxColorLookup has no entry for", () => {
64
+ const { inner } = boxes(render(<Accessibility boxed fill="var(--cb-table-sort-indicator)" />).container);
65
+
66
+ expect(inner.style.getPropertyValue("--cb-icon-box-color")).toBe(colors.BLACK);
67
+ });
68
+
69
+ it("passes scale to the svg as a custom property rather than an attribute", () => {
70
+ const { svg } = boxes(render(<Accessibility scale={2} />).container);
71
+
72
+ expect(svg.style.getPropertyValue("--cb-icon-scale")).toBe("2");
73
+ // styled-components used to forward scale onto the element, where it is not a
74
+ // valid SVG attribute and did nothing.
75
+ expect(svg).not.toHaveAttribute("scale");
76
+ });
77
+
78
+ it("keeps an icon's own transform ahead of the scale custom property", () => {
79
+ const { svg } = boxes(render(<BackArrow scale={3} />).container);
80
+
81
+ expect(svg.style.transform).toBe("scaleX(-1)");
82
+ expect(svg.style.getPropertyValue("--cb-icon-scale")).toBe("3");
83
+ });
84
+
85
+ it("leaks none of the transient-style props onto the DOM", () => {
86
+ const rendered = [
87
+ boxes(render(<Accessibility />).container),
88
+ boxes(render(<Accessibility boxed />).container),
89
+ boxes(render(<Accessibility round />).container),
90
+ ];
91
+
92
+ for (const { outer, inner, svg } of rendered) {
93
+ for (const el of [outer, inner, svg]) {
94
+ for (const name of el.getAttributeNames()) {
95
+ expect(name).not.toContain("$");
96
+ expect(["boxedsize", "boxcolor", "paddingstring", "round", "scale"]).not.toContain(name);
97
+ }
98
+ }
99
+ }
100
+ });
101
+
102
+ it("threads fill onto the drawn shapes", () => {
103
+ const { container } = render(<Accessibility fill="var(--cb-test-colour)" />);
104
+
105
+ const paths = Array.from(container.querySelectorAll("path"));
106
+ expect(paths.length).toBeGreaterThan(0);
107
+ for (const path of paths) {
108
+ expect(path).toHaveAttribute("fill", "var(--cb-test-colour)");
109
+ }
110
+ });
111
+
112
+ it("labels a titled icon and hides an untitled one", () => {
113
+ const { svg: titled, ...rest } = boxes(render(<Accessibility title="Accessibility" />).container);
114
+ const titleId = titled.getAttribute("aria-labelledby");
115
+
116
+ expect(titleId).toBeTruthy();
117
+ expect(rest.outer.querySelector(`title#${titleId}`)).toHaveTextContent("Accessibility");
118
+ expect(titled).not.toHaveAttribute("aria-hidden");
119
+
120
+ const { svg: untitled } = boxes(render(<Accessibility />).container);
121
+ expect(untitled).toHaveAttribute("aria-hidden", "true");
122
+ expect(untitled.querySelector("title")).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("every exported icon", () => {
127
+ const names = Object.keys(iconList);
128
+ const modes: Array<[string, IconProps]> = [
129
+ ["plain", {}],
130
+ ["boxed", { boxed: true }],
131
+ ["round", { round: true }],
132
+ ];
133
+
134
+ it.each(names.flatMap((name) => modes.map(([mode, props]): [string, string, IconProps] => [name, mode, props])))(
135
+ "%s renders an svg when %s",
136
+ (name, _mode, props) => {
137
+ const Icon = (iconList as Record<string, IconType>)[name];
138
+
139
+ const { container } = render(<Icon {...props} title={name} />);
140
+ expect(container.querySelector("svg")).toBeInTheDocument();
141
+ }
142
+ );
143
+ });
144
+
145
+ // These live outside src/components/Icons but build on the same four primitives, so the
146
+ // barrel-driven sweep above never reaches them.
147
+ describe("the icons built on the primitives from elsewhere", () => {
148
+ it.each([
149
+ ["Cerebrum", Cerebrum],
150
+ ["Cortex", Cortex],
151
+ ["Neuron", Neuron],
152
+ ])("%s renders an svg", (name, Icon) => {
153
+ const { container } = render(<Icon boxed title={name} />);
154
+ expect(container.querySelector("svg")).toBeInTheDocument();
155
+ });
156
+
157
+ it("Cognition renders an image rather than an svg", () => {
158
+ const { inner } = boxes(render(<Cognition boxed />).container);
159
+
160
+ expect(inner).toHaveClass("innerIconBox");
161
+ expect(inner.firstElementChild?.tagName).toBe("IMG");
162
+ });
163
+
164
+ it("ResizeIcon leaves the box colour to the stylesheet fallback", () => {
165
+ const { inner } = boxes(render(<ResizeIcon boxed />).container);
166
+
167
+ expect(inner).toHaveClass("innerIconBox");
168
+ expect(inner.style.getPropertyValue("--cb-icon-box-color")).toBe("");
169
+ });
170
+ });
171
+
172
+ describe("the icon stylesheets", () => {
173
+ const dir = __dirname;
174
+ const modules = ["IconComponentStyles.module.css", "IconList.module.css"];
175
+ const read = (file: string) => readFileSync(join(dir, file), "utf8");
176
+ const withoutComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
177
+
178
+ it.each(modules)("%s avoids native nesting", (file) => {
179
+ // Consumers compile these modules with their own pipeline, so nesting is unsafe.
180
+ // Nesting needs no "&", so the real check is a block inside a block — with at-rule
181
+ // blocks such as @media stripped first, since those legitimately wrap rules.
182
+ const css = withoutComments(read(file)).replace(/@[\w-]+[^{]*\{/g, "");
183
+
184
+ expect(css).not.toContain("&");
185
+ expect(css).not.toMatch(/\{[^{}]*\{/);
186
+ });
187
+
188
+ it.each(["IconComponentStyles", "IconList"])("%s only uses classes its stylesheet declares", (name) => {
189
+ const declared = new Set(Array.from(withoutComments(read(`${name}.module.css`)).matchAll(/\.([a-zA-Z][\w-]*)/g), (m) => m[1]));
190
+ const used = Array.from(readFileSync(join(dir, `${name}.tsx`), "utf8").matchAll(/\bstyles\.([\w$]+)/g), (m) => m[1]);
191
+
192
+ // Jest maps CSS to identity-obj-proxy, so a class missing from the stylesheet still
193
+ // renders its own name and every toHaveClass above would pass regardless.
194
+ expect(used.length).toBeGreaterThan(0);
195
+ for (const className of used) {
196
+ expect([...declared]).toContain(className);
197
+ }
198
+ });
199
+
200
+ it("positions the svg absolutely only for unboxed icons", () => {
201
+ const css = withoutComments(read("IconComponentStyles.module.css"));
202
+ const boringSpanSvg = css.match(/\.boringSpan svg \{([^}]*)\}/);
203
+
204
+ expect(boringSpanSvg?.[1]).toContain("position: absolute");
205
+ expect(css.match(/position: absolute/g)).toHaveLength(1);
206
+ });
207
+
208
+ it("keeps the icon directory free of styled-components", () => {
209
+ const sources = (function walk(from: string): string[] {
210
+ return readdirSync(from, { withFileTypes: true }).flatMap((entry) => {
211
+ const path = join(from, entry.name);
212
+ if (entry.isDirectory()) return walk(path);
213
+ return (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) && entry.name !== "Icons.test.tsx" ? [path] : [];
214
+ });
215
+ })(dir);
216
+
217
+ expect(sources.length).toBeGreaterThan(180);
218
+ for (const path of sources) {
219
+ expect(readFileSync(path, "utf8")).not.toMatch(/["']styled-components/);
220
+ }
221
+ });
222
+ });
@@ -18,11 +18,14 @@ export interface IconListProps {
18
18
  round?: boolean;
19
19
  }
20
20
 
21
+ // Every prop is optional: BoringSpan stands in for both boxes on an unboxed icon, and
22
+ // ResizeIcon renders InnerIconBox without a box colour. The CSS carries a fallback for
23
+ // each one.
21
24
  export interface IconBoxProps {
22
- $boxedSize: number;
25
+ $boxedSize?: number;
23
26
  }
24
27
  export interface InnerIconBoxProps {
25
- $boxColor: string;
28
+ $boxColor?: string;
26
29
  $paddingString?: string;
27
30
  $round?: boolean;
28
31
  }
package/src/index.ts CHANGED
@@ -687,6 +687,7 @@ export {
687
687
  export type {
688
688
  DataTableBarProps,
689
689
  DataTableBodyProps,
690
+ DataTableColumnMeta,
690
691
  DataTableContentProps,
691
692
  DataTableDensity,
692
693
  DataTableFilterProps,
@@ -167,7 +167,13 @@
167
167
  gap: 6px;
168
168
  letter-spacing: inherit;
169
169
  padding: 0;
170
- text-align: left;
170
+ /*
171
+ * Only here to undo the UA's `text-align: center` on a button, which shows up
172
+ * when a header label wraps to a second line. `inherit` does that just as well
173
+ * as `left` did -- the th resolves to left unless it is aligned -- and picks up
174
+ * a `meta.align` for free instead of needing a fourth rule to override it.
175
+ */
176
+ text-align: inherit;
171
177
  }
172
178
 
173
179
  .sortControl.sortControl:focus-visible {
@@ -206,16 +212,120 @@
206
212
  /*
207
213
  * A `meta.fixedWidth` column is the track, so the cell keeps none of the 16px
208
214
  * inset the auto-width columns carry -- the old ColumnType.fixedWidth columns were
209
- * exact grid tracks. The cell still sets its width inline (see fixedWidthProps),
215
+ * exact grid tracks. The cell still sets its width inline (see cellProps),
210
216
  * because a class cannot carry a per-column number.
211
217
  *
212
218
  * The compact rules above reach (0,5,0) and so does this, which is a tie -- broken
213
219
  * by source order, which is decided here rather than by the consumer's bundler,
214
- * since both rules live in this file. That is why this sits below them.
220
+ * since both rules live in this file. That is why this sits below them. Naming only
221
+ * the two sides is also what leaves the compact density's 8px top and bottom in
222
+ * place, rather than having to restate them.
215
223
  */
216
224
  .table.table .td.td[data-fixed-width],
217
225
  .table.table .th.th[data-fixed-width] {
218
- padding: 0;
226
+ padding-left: 0;
227
+ padding-right: 0;
228
+ }
229
+
230
+ /*
231
+ * A column's `meta.align`. The two halves are deliberately asymmetric, per the
232
+ * doubling rule at the top of this file: Mantine declares `text-align: left` on
233
+ * the th (and `right` under [dir="rtl"]) at (0,1,0), so that half is doubled to
234
+ * beat it; nothing declares it on the td, so that half is left undoubled.
235
+ *
236
+ * Note the attribute already carries a point of specificity, so even the
237
+ * undoubled half lands at (0,2,0) rather than a class's (0,1,0) -- a consumer
238
+ * overriding it needs at least that much, or the inline `style` the file header
239
+ * points them at. Undoubled still buys them the cheaper target of the two.
240
+ *
241
+ * Nothing above declares text-align on a cell, so unlike the fixed-width reset
242
+ * these do not depend on where in the file they sit.
243
+ *
244
+ * Physical left/right, not the logical properties: there is no RTL anywhere in
245
+ * this package, and the rest of the module is physical too.
246
+ */
247
+ .td[data-align="left"],
248
+ .th.th[data-align="left"] {
249
+ text-align: left;
250
+ }
251
+
252
+ .td[data-align="center"],
253
+ .th.th[data-align="center"] {
254
+ text-align: center;
255
+ }
256
+
257
+ .td[data-align="right"],
258
+ .th.th[data-align="right"] {
259
+ text-align: right;
260
+ }
261
+
262
+ /*
263
+ * The sortable header needs its own pair, and text-align cannot stand in for it.
264
+ * .sortControl is a <button>, which sizes to fit-content under `width: auto` even
265
+ * with `display: flex` -- so it hugs its label at the cell's start edge, where
266
+ * text-align has no inline content left to move and justify-content has no slack
267
+ * to distribute. Giving it the cell's width supplies that slack.
268
+ *
269
+ * Gated on the aligned values rather than declared on the base: a full-width
270
+ * button would stretch every existing sortable header's focus ring from "hugs the
271
+ * label" to "spans the cell". Aligned headers accept that; unaligned ones should
272
+ * not have to.
273
+ */
274
+ .th.th[data-align="center"] .sortControl {
275
+ justify-content: center;
276
+ width: 100%;
277
+ }
278
+
279
+ .th.th[data-align="right"] .sortControl {
280
+ justify-content: flex-end;
281
+ width: 100%;
282
+ }
283
+
284
+ /*
285
+ * A column's `meta.textStyle` -- the legacy BodyS* set, which was all 15px on a
286
+ * 22.5px line (theme.text.lineHeightSmall) and differed only in weight and colour.
287
+ *
288
+ * Single-class throughout: Mantine declares none of these on a td, and the value
289
+ * a cell would otherwise use is inherited from the table, which loses to any
290
+ * declared value whatever its specificity. So there is nothing here to outrank.
291
+ * The attribute still puts these at (0,2,0), above a bare class -- a consumer
292
+ * overriding them needs equal specificity or an inline `style`.
293
+ *
294
+ * Only `secondary` needs a custom property: COOL_GREY_100 and COOL_GREY_65 are
295
+ * gray[9] and gray[8], and both are already black in the high-contrast tuple, so
296
+ * they need no theme branch. COOL_GREY_75 has no shade -- see mantineTheme.ts.
297
+ */
298
+ .td[data-text-style] {
299
+ font-size: 15px;
300
+ /*
301
+ * Declared, not inherited: every BodyS* carried its own weight, so `primary`,
302
+ * `secondary` and `tertiary` reset a bolder cell rather than picking it up.
303
+ * `emphasis` below ties this at (0,2,0) and wins on source order, which is this
304
+ * file's to decide since both rules live in it.
305
+ */
306
+ font-weight: 400;
307
+ line-height: 22.5px;
308
+ }
309
+
310
+ .td[data-text-style="primary"] {
311
+ color: var(--mantine-color-gray-9);
312
+ letter-spacing: 0.36px;
313
+ }
314
+
315
+ .td[data-text-style="emphasis"] {
316
+ color: var(--mantine-color-gray-9);
317
+ font-weight: 700;
318
+ letter-spacing: 0.39px;
319
+ }
320
+
321
+ .td[data-text-style="secondary"] {
322
+ color: var(--cb-table-cell-secondary);
323
+ letter-spacing: 0.39px;
324
+ }
325
+
326
+ .td[data-text-style="tertiary"] {
327
+ color: var(--mantine-color-gray-8);
328
+ letter-spacing: 0.39px;
219
329
  }
220
330
 
221
331
  /* rowBorderRadius: 4. Only the outer cells round, so the row reads as one shape. */