@ultimat3/ui 22.14.0 → 23.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 (47) hide show
  1. package/CATALOG.md +43 -12
  2. package/CLAUDE.md +13 -49
  3. package/README.md +59 -4
  4. package/package.json +6 -5
  5. package/src/catalog/parse-component.ts +85 -12
  6. package/src/catalog/render-catalog.ts +21 -1
  7. package/src/components/Avatar.module.scss +1 -1
  8. package/src/components/BarChart.module.scss +12 -1
  9. package/src/components/BarChart.tsx +41 -15
  10. package/src/components/Button.tsx +3 -8
  11. package/src/components/Card.module.scss +1 -1
  12. package/src/components/Checkbox.module.scss +4 -4
  13. package/src/components/Checkbox.tsx +6 -0
  14. package/src/components/CommandPalette.module.scss +1 -1
  15. package/src/components/DataTable.module.scss +2 -0
  16. package/src/components/DataTable.tsx +90 -29
  17. package/src/components/Dialog.module.scss +2 -2
  18. package/src/components/Dropzone.module.scss +3 -3
  19. package/src/components/FileInput.module.scss +1 -1
  20. package/src/components/Form.module.scss +2 -2
  21. package/src/components/Input.module.scss +1 -1
  22. package/src/components/Link.tsx +64 -12
  23. package/src/components/Pagination.tsx +89 -37
  24. package/src/components/Radio.module.scss +2 -2
  25. package/src/components/Select.module.scss +3 -3
  26. package/src/components/Skeleton.module.scss +1 -1
  27. package/src/components/Spinner.module.scss +3 -3
  28. package/src/components/Switch.module.scss +2 -2
  29. package/src/components/Table.module.scss +2 -2
  30. package/src/components/Tabs.module.scss +5 -5
  31. package/src/components/ThemeToggle.module.scss +1 -1
  32. package/src/components/Toast.module.scss +2 -2
  33. package/src/components/Tooltip.module.scss +1 -1
  34. package/src/components/bar-chart-view.ts +29 -3
  35. package/src/components/button-classes.ts +25 -0
  36. package/src/components/link-classes.ts +38 -0
  37. package/src/components/link-target.ts +13 -0
  38. package/src/index.ts +13 -3
  39. package/src/sass-probe.ts +55 -0
  40. package/src/tokens/_index.scss +3 -1
  41. package/src/tokens/_mixins.scss +56 -8
  42. package/src/tokens/_motion.scss +4 -0
  43. package/src/tokens/_stroke.scss +20 -0
  44. package/src/tokens/_units.scss +47 -0
  45. package/src/tokens/reset.scss +4 -4
  46. package/src/tokens/theme.scss +2 -0
  47. package/src/tokens/tokens.ts +6 -0
@@ -1,32 +1,63 @@
1
1
  // Cursor-first pagination — the only shape a Postgres-backed list should use.
2
2
  // Numbered mode exists for prerendered archives where the total is known.
3
+ // One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a
4
+ // server-rendered list — anchors the browser follows, with no script on the page.
3
5
 
4
6
  import type { JSX } from 'solid-js';
5
7
  import { cx } from '../cx';
6
8
  import { UI_KEYS } from '../i18n-keys';
7
9
  import { useUi } from '../theme/context';
8
10
  import { Button } from './Button';
11
+ import { Link } from './Link';
9
12
  import styles from './Pagination.module.scss';
10
13
 
11
- export interface PaginationProps {
14
+ type PageDirection = 'next' | 'prev';
15
+
16
+ interface PaginationBaseProps {
12
17
  /** Opaque cursor for the following page; absent disables "next". */
13
18
  nextCursor?: string | undefined;
14
19
  prevCursor?: string | undefined;
20
+ /** Already-translated; falls back to the ui.* catalog keys. */
21
+ labelPrevious?: string | undefined;
22
+ labelNext?: string | undefined;
23
+ class?: string | undefined;
24
+ }
25
+
26
+ /** Callback mode: the page is an island and pages itself. */
27
+ export interface PaginationCallbackProps extends PaginationBaseProps {
28
+ /** Callback mode. Never beside `hrefFor` — the type refuses the pair. */
15
29
  onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
16
30
  /** Numbered mode: 1-based page and total. Ignored when cursors are present. */
17
31
  page?: number | undefined;
18
32
  totalPages?: number | undefined;
19
33
  onPage?: ((page: number) => void) | undefined;
20
- /** Already-translated; falls back to the ui.* catalog keys. */
21
- labelPrevious?: string | undefined;
22
- labelNext?: string | undefined;
23
- class?: string | undefined;
34
+ hrefFor?: undefined;
24
35
  }
25
36
 
37
+ /** Link mode: each cursor is a URL, so the pager works with scripting off. */
38
+ export interface PaginationLinkProps extends PaginationBaseProps {
39
+ /**
40
+ * Link mode: the URL of the page a cursor names. Renders `<a rel="next|prev">` and attaches no
41
+ * handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`,
42
+ * `page` or `totalPages`.
43
+ */
44
+ hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
45
+ onCursor?: undefined;
46
+ page?: undefined;
47
+ totalPages?: undefined;
48
+ onPage?: undefined;
49
+ }
50
+
51
+ export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
52
+
26
53
  export function Pagination(props: PaginationProps): JSX.Element {
27
54
  const ui = useUi();
28
- const previous = (): string => props.labelPrevious ?? ui.t(UI_KEYS.previous);
29
- const next = (): string => props.labelNext ?? ui.t(UI_KEYS.next);
55
+ const label = (direction: PageDirection): string =>
56
+ direction === 'prev'
57
+ ? (props.labelPrevious ?? ui.t(UI_KEYS.previous))
58
+ : (props.labelNext ?? ui.t(UI_KEYS.next));
59
+ const cursor = (direction: PageDirection): string | undefined =>
60
+ direction === 'prev' ? props.prevCursor : props.nextCursor;
30
61
  // A cursor present means cursor mode, exactly as `page`'s own doc says. The inverted form —
31
62
  // "cursor mode when a number is MISSING" — silently dropped the cursor and paged by number the
32
63
  // moment a caller passed both, which is the shape a list that knows its total naturally has.
@@ -36,23 +67,62 @@ export function Pagination(props: PaginationProps): JSX.Element {
36
67
  props.page === undefined ||
37
68
  props.totalPages === undefined;
38
69
 
39
- return (
40
- <nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
70
+ const exhausted = (direction: PageDirection): boolean => {
71
+ if (cursorMode()) return cursor(direction) === undefined;
72
+ return direction === 'prev'
73
+ ? (props.page ?? 1) <= 1
74
+ : (props.page ?? 1) >= (props.totalPages ?? 1);
75
+ };
76
+
77
+ const go = (direction: PageDirection): void => {
78
+ const to = cursor(direction);
79
+ if (cursorMode()) {
80
+ if (to !== undefined) props.onCursor?.(to, direction);
81
+ } else if (direction === 'prev') {
82
+ props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
83
+ } else {
84
+ props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
85
+ }
86
+ };
87
+
88
+ /**
89
+ * One side of the pager. In link mode a side WITH a cursor is an anchor and a side without one
90
+ * is the same disabled button callback mode shows: an anchor with nowhere to go is a dead
91
+ * control that still takes a tab stop, and dropping the side would move the other one between
92
+ * the first page and the second.
93
+ */
94
+ const side = (direction: PageDirection): JSX.Element => {
95
+ const to = cursor(direction);
96
+ if (props.hrefFor !== undefined && to !== undefined) {
97
+ return (
98
+ <Link
99
+ appearance="button"
100
+ variant="secondary"
101
+ size="sm"
102
+ tone="neutral"
103
+ rel={direction}
104
+ href={props.hrefFor(to, direction)}
105
+ >
106
+ {label(direction)}
107
+ </Link>
108
+ );
109
+ }
110
+ return (
41
111
  <Button
42
112
  variant="secondary"
43
113
  size="sm"
44
114
  tone="neutral"
45
- disabled={cursorMode() ? props.prevCursor === undefined : (props.page ?? 1) <= 1}
46
- onClick={() => {
47
- if (cursorMode()) {
48
- if (props.prevCursor !== undefined) props.onCursor?.(props.prevCursor, 'prev');
49
- } else {
50
- props.onPage?.(Math.max(1, (props.page ?? 1) - 1));
51
- }
52
- }}
115
+ disabled={exhausted(direction)}
116
+ onClick={props.hrefFor === undefined ? () => go(direction) : undefined}
53
117
  >
54
- {previous()}
118
+ {label(direction)}
55
119
  </Button>
120
+ );
121
+ };
122
+
123
+ return (
124
+ <nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
125
+ {side('prev')}
56
126
 
57
127
  {cursorMode() ? null : (
58
128
  <span class={styles['status']} aria-live="polite">
@@ -60,25 +130,7 @@ export function Pagination(props: PaginationProps): JSX.Element {
60
130
  </span>
61
131
  )}
62
132
 
63
- <Button
64
- variant="secondary"
65
- size="sm"
66
- tone="neutral"
67
- disabled={
68
- cursorMode()
69
- ? props.nextCursor === undefined
70
- : (props.page ?? 1) >= (props.totalPages ?? 1)
71
- }
72
- onClick={() => {
73
- if (cursorMode()) {
74
- if (props.nextCursor !== undefined) props.onCursor?.(props.nextCursor, 'next');
75
- } else {
76
- props.onPage?.(Math.min(props.totalPages ?? 1, (props.page ?? 1) + 1));
77
- }
78
- }}
79
- >
80
- {next()}
81
- </Button>
133
+ {side('next')}
82
134
  </nav>
83
135
  );
84
136
  }
@@ -67,8 +67,8 @@
67
67
  }
68
68
 
69
69
  .input:focus-visible + .dot {
70
- outline: 2px solid t.role('accent');
71
- outline-offset: 2px;
70
+ outline: t.stroke(thick) solid t.role('accent');
71
+ outline-offset: t.stroke(thick);
72
72
  }
73
73
 
74
74
  .text {
@@ -30,9 +30,9 @@
30
30
  inset-inline-end: t.space(3);
31
31
  inline-size: 0.4rem;
32
32
  block-size: 0.4rem;
33
- border-inline-end: 2px solid t.role('fg-muted');
34
- border-block-end: 2px solid t.role('fg-muted');
35
- transform: translateY(-2px) rotate(45deg);
33
+ border-inline-end: t.stroke(thick) solid t.role('fg-muted');
34
+ border-block-end: t.stroke(thick) solid t.role('fg-muted');
35
+ transform: translateY(t.rem(-2px)) rotate(45deg);
36
36
  pointer-events: none;
37
37
  }
38
38
 
@@ -24,7 +24,7 @@
24
24
  t.role('bg-soft') 63%
25
25
  )
26
26
  0 0 / 200% 100%;
27
- animation: ultimate-shimmer 1.4s linear infinite;
27
+ animation: ultimate-shimmer calc(#{t.duration(slower)} * 2) linear infinite;
28
28
  }
29
29
 
30
30
  .shape-text {
@@ -8,10 +8,10 @@
8
8
 
9
9
  .spinner {
10
10
  display: inline-block;
11
- border: 2px solid t.role('line');
11
+ border: t.stroke(thick) solid t.role('line');
12
12
  border-block-start-color: t.role('accent');
13
13
  border-radius: t.radius(full);
14
- animation: ultimate-spin 700ms linear infinite;
14
+ animation: ultimate-spin t.duration(slower) linear infinite;
15
15
  // Reduced motion is handled globally in reset.scss; the ring still reads as
16
16
  // a distinct shape when the animation is neutralised.
17
17
  }
@@ -29,5 +29,5 @@
29
29
  .size-lg {
30
30
  inline-size: 2rem;
31
31
  block-size: 2rem;
32
- border-width: 3px;
32
+ border-width: t.stroke(heavy);
33
33
  }
@@ -57,8 +57,8 @@
57
57
  }
58
58
 
59
59
  .input:focus-visible + .track {
60
- outline: 2px solid t.role('accent');
61
- outline-offset: 2px;
60
+ outline: t.stroke(thick) solid t.role('accent');
61
+ outline-offset: t.stroke(thick);
62
62
  }
63
63
 
64
64
  .text {
@@ -10,8 +10,8 @@
10
10
  background: t.role('surface');
11
11
 
12
12
  &:focus-visible {
13
- outline: 2px solid t.role('accent');
14
- outline-offset: -2px;
13
+ outline: t.stroke(thick) solid t.role('accent');
14
+ outline-offset: calc(-1 * #{t.stroke(thick)});
15
15
  }
16
16
  }
17
17
 
@@ -19,7 +19,7 @@
19
19
  }
20
20
 
21
21
  .tab[aria-selected='true'] {
22
- box-shadow: inset -2px 0 0 0 t.role('accent');
22
+ box-shadow: inset calc(-1 * #{t.stroke(thick)}) 0 0 0 t.role('accent');
23
23
  }
24
24
  }
25
25
 
@@ -32,7 +32,7 @@
32
32
  }
33
33
 
34
34
  .tab {
35
- @include t.focus-ring(('accent'), 2px, -2px);
35
+ @include t.focus-ring('accent', t.stroke(thick), calc(-1 * #{t.stroke(thick)}));
36
36
  @include t.transition(color, box-shadow, background);
37
37
  padding-block: t.space(2);
38
38
  padding-inline: t.space(3);
@@ -54,7 +54,7 @@
54
54
  &[aria-selected='true'] {
55
55
  color: t.role('accent');
56
56
  // Inset shadow instead of a border, so the tab does not shift on select.
57
- box-shadow: inset 0 -2px 0 0 t.role('accent');
57
+ box-shadow: inset 0 calc(-1 * #{t.stroke(thick)}) 0 0 t.role('accent');
58
58
  }
59
59
  }
60
60
 
@@ -62,7 +62,7 @@
62
62
  min-inline-size: 0;
63
63
 
64
64
  &:focus-visible {
65
- outline: 2px solid t.role('accent');
66
- outline-offset: 2px;
65
+ outline: t.stroke(thick) solid t.role('accent');
66
+ outline-offset: t.stroke(thick);
67
67
  }
68
68
  }
@@ -18,6 +18,6 @@
18
18
 
19
19
  &[data-theme-glyph='dark'] {
20
20
  background: transparent;
21
- box-shadow: inset -0.28rem -0.18rem 0 0 currentcolor;
21
+ box-shadow: inset t.rem(-4.5px) t.rem(-3px) 0 0 currentcolor;
22
22
  }
23
23
  }
@@ -38,7 +38,7 @@
38
38
  align-items: flex-start;
39
39
  gap: t.space(3);
40
40
  padding: t.space(4);
41
- border-inline-start: 3px solid var(--toast-accent);
41
+ border-inline-start: t.stroke(heavy) solid var(--toast-accent);
42
42
  color: t.role('fg');
43
43
  pointer-events: auto;
44
44
 
@@ -56,7 +56,7 @@
56
56
 
57
57
  @starting-style {
58
58
  opacity: 0;
59
- translate: 0 12px;
59
+ translate: 0 t.space(3);
60
60
  }
61
61
  }
62
62
 
@@ -10,7 +10,7 @@
10
10
  position: absolute;
11
11
  z-index: t.z(tooltip);
12
12
  inset-inline-start: 50%;
13
- translate: -50% 4px;
13
+ translate: -50% t.space(1);
14
14
  max-inline-size: 18rem;
15
15
  padding-block: t.space(1);
16
16
  padding-inline: t.space(2);
@@ -5,6 +5,11 @@ export interface ChartPoint {
5
5
  /** The bar's name — a day, a bucket, a label. Read by `<title>` and the axis. */
6
6
  readonly key: string;
7
7
  readonly value: number;
8
+ /**
9
+ * A second series, stacked on `value` — failed on top of done. Absent or 0 draws nothing, so a
10
+ * one-series chart is exactly the chart it was.
11
+ */
12
+ readonly secondary?: number;
8
13
  }
9
14
 
10
15
  export interface BarRect {
@@ -24,11 +29,16 @@ export const BAR_CHART = { width: 600, top: 12, plot: 104, height: 128, gap: 3 }
24
29
  /** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
25
30
  export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
26
31
 
27
- /** The busiest point, and never below 1, so an all-zero series still divides. */
32
+ const finite = (value: number | undefined): number =>
33
+ value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0;
34
+
35
+ /** The busiest point — its whole stack — and never below 1, so an all-zero series still divides. */
28
36
  export function maxOf(points: readonly ChartPoint[]): number {
29
37
  let max = 1;
30
- for (const point of points)
31
- if (Number.isFinite(point.value) && point.value > max) max = point.value;
38
+ for (const point of points) {
39
+ const total = finite(point.value) + finite(point.secondary);
40
+ if (total > max) max = total;
41
+ }
32
42
  return max;
33
43
  }
34
44
 
@@ -53,3 +63,19 @@ export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
53
63
  };
54
64
  });
55
65
  }
66
+
67
+ /**
68
+ * The second series' rect per point, stacked on that point's primary bar; `null` where it is 0.
69
+ * Same scale as `barRects`, so a stack is read against the same grid lines.
70
+ */
71
+ export function secondaryRects(points: readonly ChartPoint[]): readonly (BarRect | null)[] {
72
+ const primary = barRects(points);
73
+ const max = maxOf(points);
74
+ return points.map((point, index) => {
75
+ const value = finite(point.secondary);
76
+ const below = primary[index];
77
+ if (value === 0 || below === undefined) return null;
78
+ const height = (value / max) * BAR_CHART.plot;
79
+ return { x: below.x, y: below.y - height, width: below.width, height };
80
+ });
81
+ }
@@ -0,0 +1,25 @@
1
+ // The classes a button's look is made of — asked by `<Button>` and by `<Link appearance="button">`,
2
+ // so a control that submits and a control that navigates cannot drift into two looks. Keys into
3
+ // `Button.module.scss`, never class names: the scoped name is the stylesheet's to give.
4
+
5
+ import type { ButtonVariant, Size, Tone } from './variants';
6
+
7
+ export interface ButtonLook {
8
+ variant?: ButtonVariant | undefined;
9
+ tone?: Tone | undefined;
10
+ size?: Size | undefined;
11
+ fullWidth?: boolean | undefined;
12
+ }
13
+
14
+ /** The classes of the element's CONTENT, which both elements wrap their label in. */
15
+ export const BUTTON_CONTENT_KEYS = ['label', 'spinner'] as const;
16
+
17
+ export function buttonClassKeys(look: ButtonLook): readonly string[] {
18
+ return [
19
+ 'button',
20
+ `variant-${look.variant ?? 'primary'}`,
21
+ `tone-${look.tone ?? 'accent'}`,
22
+ `size-${look.size ?? 'md'}`,
23
+ ...(look.fullWidth === true ? ['full'] : []),
24
+ ];
25
+ }
@@ -0,0 +1,38 @@
1
+ // Which stylesheet a `<Link>` reads and which of its classes — the one decision `appearance`
2
+ // makes. Pure, so it is testable with no renderer: the `.tsx` only maps the answer onto a sheet.
3
+
4
+ import { type ButtonLook, buttonClassKeys } from './button-classes';
5
+ import type { Tone } from './variants';
6
+
7
+ export interface LinkLook extends Omit<ButtonLook, 'tone'> {
8
+ appearance?: 'link' | 'button' | undefined;
9
+ underline?: 'always' | 'hover' | 'none' | undefined;
10
+ /** A text link's own two tones, or — as a button — any `Tone`. */
11
+ tone?: Tone | 'inherit' | undefined;
12
+ }
13
+
14
+ export interface LinkClasses {
15
+ /** `button` is `Button.module.scss`: one set of button styles, whichever element wears them. */
16
+ readonly sheet: 'link' | 'button';
17
+ readonly keys: readonly string[];
18
+ }
19
+
20
+ export function linkClasses(look: LinkLook): LinkClasses {
21
+ if (look.appearance === 'button') {
22
+ return {
23
+ sheet: 'button',
24
+ // `inherit` is a text link's tone and no button has it; the type refuses the pairing, and a
25
+ // caller that got past the type gets the default tone rather than a class nothing declares.
26
+ keys: buttonClassKeys({
27
+ variant: look.variant,
28
+ tone: look.tone === 'inherit' ? undefined : look.tone,
29
+ size: look.size,
30
+ fullWidth: look.fullWidth,
31
+ }),
32
+ };
33
+ }
34
+ return {
35
+ sheet: 'link',
36
+ keys: ['link', `underline-${look.underline ?? 'hover'}`, `tone-${look.tone ?? 'accent'}`],
37
+ };
38
+ }
@@ -21,3 +21,16 @@ export function linkTarget(href: string, declaredExternal?: boolean | undefined)
21
21
  if (safe === null) return { href: undefined, external: false };
22
22
  return { href: safe, external: declaredExternal === true || /^https?:\/\//.test(safe) };
23
23
  }
24
+
25
+ /**
26
+ * The `rel` an anchor carries. A relation is ADDED to the external hardening, never traded for
27
+ * it — which is why `<Link>` takes the relation as a closed pair and not a free `rel` string a
28
+ * caller could use to write the hardening away.
29
+ */
30
+ export function linkRel(
31
+ external: boolean,
32
+ relation?: 'next' | 'prev' | undefined,
33
+ ): string | undefined {
34
+ if (!external) return relation;
35
+ return relation === undefined ? 'noopener noreferrer' : `${relation} noopener noreferrer`;
36
+ }
package/src/index.ts CHANGED
@@ -89,7 +89,12 @@ export {
89
89
  settleActive,
90
90
  stepActive,
91
91
  } from './components/command-palette-view';
92
- export type { Column, DataTableProps } from './components/DataTable';
92
+ export type {
93
+ Column,
94
+ DataTableCallbackProps,
95
+ DataTableLinkProps,
96
+ DataTableProps,
97
+ } from './components/DataTable';
93
98
  export { DataTable } from './components/DataTable';
94
99
  export type { DateTimeProps } from './components/DateTime';
95
100
  export { DateTime } from './components/DateTime';
@@ -160,7 +165,7 @@ export { boxFor, reservedRatio, sourceSetsFor, srcsetFor } from './components/im
160
165
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
161
166
  export type { KbdProps } from './components/Kbd';
162
167
  export { Kbd } from './components/Kbd';
163
- export type { LinkProps } from './components/Link';
168
+ export type { ButtonLinkProps, LinkProps, TextLinkProps } from './components/Link';
164
169
  export { Link } from './components/Link';
165
170
  export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
166
171
  export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
@@ -176,7 +181,11 @@ export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/
176
181
  export { moneyText, toMoney } from './components/money-view';
177
182
  export type { PageHeaderProps } from './components/PageHeader';
178
183
  export { PageHeader } from './components/PageHeader';
179
- export type { PaginationProps } from './components/Pagination';
184
+ export type {
185
+ PaginationCallbackProps,
186
+ PaginationLinkProps,
187
+ PaginationProps,
188
+ } from './components/Pagination';
180
189
  export { Pagination } from './components/Pagination';
181
190
  export type { Placement, PopoverProps } from './components/Popover';
182
191
  export { Popover } from './components/Popover';
@@ -378,5 +387,6 @@ export {
378
387
  radiusTokens,
379
388
  shadowTokens,
380
389
  spaceTokens,
390
+ strokeTokens,
381
391
  zTokens,
382
392
  } from './tokens/tokens';
@@ -0,0 +1,55 @@
1
+ // TEST-ONLY. Compiles a stylesheet with the compiler an app's build uses, so a test can read what
2
+ // Sass EMITS — a media query, a `clamp()`, the class list a mixin generates, an `@error` — instead
3
+ // of the source it was written as. Never exported from `index.ts`, like `jsx-probe.ts`.
4
+
5
+ /** The two members of Sass's API this file calls; the package itself is not a dependency of `ui`. */
6
+ interface SassApi {
7
+ compileString(source: string, options: { readonly url: URL }): { readonly css: string };
8
+ }
9
+
10
+ let loading: Promise<SassApi> | undefined;
11
+
12
+ /**
13
+ * `sass` is `@ultimat3/render`'s dependency — the package that compiles an app's sheets — and `ui`
14
+ * may not import `render` (tier 4, both). Resolved FROM render's directory, so a test compiles with
15
+ * the one compiler at the one version the lockfile pins and `ui` declares no second copy. Only a
16
+ * specifier is read from render; no module of it loads. Lazy, so importing this file has no effect.
17
+ */
18
+ function sassCompiler(): Promise<SassApi> {
19
+ loading ??= import(
20
+ Bun.resolveSync('sass', new URL('../../render', import.meta.url).pathname)
21
+ ) as Promise<SassApi>;
22
+ return loading;
23
+ }
24
+
25
+ /** Compile `source` as though it were the file at `path`, so its relative `@use`s resolve. */
26
+ export async function compileScss(source: string, path: string): Promise<string> {
27
+ const sass = await sassCompiler();
28
+ return sass.compileString(source, { url: new URL(`file://${path}`) }).css;
29
+ }
30
+
31
+ /** Compile a stylesheet that exists on disk. */
32
+ export async function compileScssFile(path: string): Promise<string> {
33
+ return compileScss(await Bun.file(path).text(), path);
34
+ }
35
+
36
+ /**
37
+ * The `@error` text Sass refused `source` with, or `undefined` when it compiled. A refusal that is
38
+ * not Sass's own — the compiler failing to load — is rethrown: it is not an answer about `source`.
39
+ */
40
+ export async function scssRefusal(source: string, path: string): Promise<string | undefined> {
41
+ try {
42
+ await compileScss(source, path);
43
+ } catch (error) {
44
+ if (typeof error === 'object' && error !== null && 'sassMessage' in error) {
45
+ return String(error.sassMessage);
46
+ }
47
+ throw error;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ /** Every class a compiled stylesheet declares. */
53
+ export function declaredClasses(css: string): ReadonlySet<string> {
54
+ return new Set([...css.matchAll(/\.([A-Za-z][\w-]*)/g)].map((match) => match[1] as string));
55
+ }
@@ -1,5 +1,5 @@
1
1
  // Single entry point for authors: `@use '../tokens' as t;` then `t.role('bg')`,
2
- // `t.space(4)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
2
+ // `t.space(4)`, `t.rem(24px)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
3
3
  // emits no CSS. Global custom properties come from `theme.scss`.
4
4
 
5
5
  @forward 'breakpoints';
@@ -8,6 +8,8 @@
8
8
  @forward 'radius' hide emit;
9
9
  @forward 'shadow' hide emit, levels;
10
10
  @forward 'space' hide emit;
11
+ @forward 'stroke' hide emit;
11
12
  @forward 'typography' hide emit;
13
+ @forward 'units';
12
14
  @forward 'z' hide emit;
13
15
  @forward 'mixins';