@lovett/ui 0.0.4 → 0.0.6

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 (59) hide show
  1. package/dist/index.d.ts +2659 -0
  2. package/dist/index.js +14451 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/styles.css +636 -0
  5. package/dist/tokens.css +700 -0
  6. package/package.json +44 -17
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/allocation-sparkbar.tsx +90 -0
  16. package/src/card.tsx +1 -1
  17. package/src/collapsible-card.tsx +85 -0
  18. package/src/data-grid/table-body.tsx +8 -1
  19. package/src/dropdown-menu.tsx +1 -1
  20. package/src/floating-status-bar.tsx +112 -0
  21. package/src/folder-tree-picker.tsx +5 -6
  22. package/src/frame-stack.tsx +27 -10
  23. package/src/hero-form-card.tsx +2 -2
  24. package/src/icons/brand.tsx +187 -0
  25. package/src/index.ts +43 -0
  26. package/src/lib/clipboard.ts +14 -0
  27. package/src/lib/color.ts +111 -0
  28. package/src/meta-cell.tsx +52 -0
  29. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  30. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  31. package/src/microsoft-logo.tsx +33 -0
  32. package/src/modal.tsx +77 -6
  33. package/src/pill-button.tsx +23 -5
  34. package/src/profile-section.tsx +40 -9
  35. package/src/sortable-table.tsx +5 -1
  36. package/src/styles.css +74 -0
  37. package/src/tabs.tsx +4 -0
  38. package/src/tag-chip-input.tsx +1 -1
  39. package/src/theme-v2.css +466 -0
  40. package/src/token-badge.tsx +92 -0
  41. package/src/tokens.css +181 -60
  42. package/src/v2/README.md +208 -0
  43. package/src/v2/__demo__/showcase.tsx +1045 -0
  44. package/src/v2/action.tsx +91 -0
  45. package/src/v2/callout.tsx +76 -0
  46. package/src/v2/document-section.tsx +82 -0
  47. package/src/v2/document-shell.tsx +0 -0
  48. package/src/v2/field-row.tsx +113 -0
  49. package/src/v2/icons.tsx +165 -0
  50. package/src/v2/index.ts +147 -0
  51. package/src/v2/layout.tsx +293 -0
  52. package/src/v2/progress-track.tsx +89 -0
  53. package/src/v2/stat-tile.tsx +129 -0
  54. package/src/v2/states.tsx +271 -0
  55. package/src/v2/status-pill.tsx +74 -0
  56. package/src/v2/theme.css +1861 -0
  57. package/src/v2/timeline.tsx +81 -0
  58. package/src/v2/tokens.ts +228 -0
  59. package/src/value-chip.tsx +76 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Modal — 51 consuming files plus 113 sub-component call sites.
3
+ *
4
+ * Note what these tests deliberately do NOT claim: Modal has no
5
+ * role="dialog", no aria-modal, no focus trap and no focus restore, so
6
+ * there is no dialog role to query by. That is a real accessibility gap
7
+ * (a keyboard user can Tab through the page behind the backdrop) and it
8
+ * is tracked separately — these tests pin the behaviour that exists so
9
+ * the a11y work can be verified as a change, not guessed at.
10
+ */
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { render, screen } from '@testing-library/react'
13
+ import userEvent from '@testing-library/user-event'
14
+ import Modal from '../modal'
15
+
16
+ const noop = () => {}
17
+
18
+ describe('Modal', () => {
19
+ it('renders nothing when closed', () => {
20
+ const { container } = render(
21
+ <Modal isOpen={false} onClose={noop} title="Confirm">
22
+ body
23
+ </Modal>,
24
+ )
25
+ expect(container).toBeEmptyDOMElement()
26
+ })
27
+
28
+ it('renders the title and body when open', () => {
29
+ render(
30
+ <Modal isOpen onClose={noop} title="Confirm">
31
+ body content
32
+ </Modal>,
33
+ )
34
+ expect(screen.getByRole('heading', { name: 'Confirm' })).toBeInTheDocument()
35
+ expect(screen.getByText('body content')).toBeInTheDocument()
36
+ })
37
+
38
+ it('closes on the labelled close button', async () => {
39
+ const onClose = vi.fn()
40
+ render(
41
+ <Modal isOpen onClose={onClose} title="Confirm">
42
+ body
43
+ </Modal>,
44
+ )
45
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
46
+ expect(onClose).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('closes on Escape', async () => {
50
+ const onClose = vi.fn()
51
+ render(
52
+ <Modal isOpen onClose={onClose} title="Confirm">
53
+ body
54
+ </Modal>,
55
+ )
56
+ await userEvent.keyboard('{Escape}')
57
+ expect(onClose).toHaveBeenCalledTimes(1)
58
+ })
59
+
60
+ it('does not listen for Escape while closed', async () => {
61
+ const onClose = vi.fn()
62
+ render(
63
+ <Modal isOpen={false} onClose={onClose} title="Confirm">
64
+ body
65
+ </Modal>,
66
+ )
67
+ await userEvent.keyboard('{Escape}')
68
+ expect(onClose).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('detaches the Escape listener on unmount', async () => {
72
+ const onClose = vi.fn()
73
+ const { unmount } = render(
74
+ <Modal isOpen onClose={onClose} title="Confirm">
75
+ body
76
+ </Modal>,
77
+ )
78
+ unmount()
79
+ await userEvent.keyboard('{Escape}')
80
+ expect(onClose).not.toHaveBeenCalled()
81
+ })
82
+
83
+ describe('Tray + Footer compose into one decision surface', () => {
84
+ // The actions belong ON the tray, not in the frame gap below it.
85
+ // Before this, Tray sat at --tray-inset (4px) and Footer on the outer
86
+ // card at px-6 (24px), so the buttons were 20px inboard of the tray
87
+ // edge they were meant to align with, and `.ds-card-tray:not(:last-child)`
88
+ // zeroed the tray's bottom margin so its lower corners were cut flush.
89
+ const renderBoth = () =>
90
+ render(
91
+ <Modal isOpen onClose={noop} title="Confirm">
92
+ <Modal.Tray>tray body</Modal.Tray>
93
+ <Modal.Footer>
94
+ <button type="button">Confirm</button>
95
+ </Modal.Footer>
96
+ </Modal>,
97
+ )
98
+
99
+ it('puts the actions INSIDE the tray', () => {
100
+ renderBoth()
101
+ const tray = document.querySelector('.ds-card-tray')
102
+ expect(tray).toHaveTextContent('tray body')
103
+ expect(tray).toHaveTextContent('Confirm')
104
+ expect(tray!.contains(screen.getByRole('button', { name: 'Confirm' }))).toBe(
105
+ true,
106
+ )
107
+ })
108
+
109
+ it('renders exactly one tray — the footer does not get its own', () => {
110
+ renderBoth()
111
+ expect(document.querySelectorAll('.ds-card-tray')).toHaveLength(1)
112
+ })
113
+
114
+ it('separates body from actions with a divider', () => {
115
+ renderBoth()
116
+ const footer = screen
117
+ .getByRole('button', { name: 'Confirm' })
118
+ .closest('div')!
119
+ expect(footer.className).toContain('border-t')
120
+ })
121
+
122
+ it('keeps a Tray standalone when there is no Footer', () => {
123
+ render(
124
+ <Modal isOpen onClose={noop} title="Confirm">
125
+ <Modal.Tray>tray body</Modal.Tray>
126
+ </Modal>,
127
+ )
128
+ const tray = document.querySelector('.ds-card-tray')
129
+ expect(tray).toHaveTextContent('tray body')
130
+ // The standalone tray owns its own padding.
131
+ expect(tray!.className).toContain('p-5')
132
+ })
133
+
134
+ it('leaves flat children alone', () => {
135
+ render(
136
+ <Modal isOpen onClose={noop} title="Confirm">
137
+ <p>flat body</p>
138
+ </Modal>,
139
+ )
140
+ expect(screen.getByText('flat body')).toBeInTheDocument()
141
+ expect(document.querySelector('.ds-card-tray')).toBeNull()
142
+ })
143
+ })
144
+
145
+ it('merges a caller className onto the panel', () => {
146
+ render(
147
+ <Modal isOpen onClose={noop} title="Confirm" className="max-w-sm">
148
+ body
149
+ </Modal>,
150
+ )
151
+ const panel = document.querySelector('.ds-card-surface')
152
+ expect(panel).toHaveClass('ds-card-surface', 'max-w-sm')
153
+ })
154
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * PageShell — 84 consuming files.
3
+ *
4
+ * Two behaviours matter to consumers and neither was verified before:
5
+ * the legacy `breadcrumb` + `title` props still resolve to the same
6
+ * crumb chain as `crumbs`, and the slot-host mode relocates the header
7
+ * instead of rendering a second sticky bar.
8
+ *
9
+ * PageShell imports <Link>, which is why every consumer inherits a hard
10
+ * dependency on react-router-dom — hence the MemoryRouter here.
11
+ */
12
+ import { describe, expect, it } from 'vitest'
13
+ import { render, screen } from '@testing-library/react'
14
+ import { MemoryRouter } from 'react-router-dom'
15
+ import {
16
+ PageShell,
17
+ PageHeaderSlotProvider,
18
+ PageHeaderHost,
19
+ } from '../page-shell'
20
+
21
+ const renderShell = (ui: React.ReactNode) =>
22
+ render(<MemoryRouter>{ui}</MemoryRouter>)
23
+
24
+ describe('PageShell', () => {
25
+ it('renders its children', () => {
26
+ renderShell(
27
+ <PageShell title="Brand">
28
+ <p>lens content</p>
29
+ </PageShell>,
30
+ )
31
+ expect(screen.getByText('lens content')).toBeInTheDocument()
32
+ })
33
+
34
+ it('renders a crumb chain, linking only the crumbs that have a `to`', () => {
35
+ renderShell(
36
+ <PageShell
37
+ crumbs={[
38
+ { label: 'Clients', to: '/clients' },
39
+ { label: 'Acme' },
40
+ { label: 'Brand' },
41
+ ]}
42
+ >
43
+ <p>x</p>
44
+ </PageShell>,
45
+ )
46
+ expect(screen.getByRole('link', { name: 'Clients' })).toHaveAttribute(
47
+ 'href',
48
+ '/clients',
49
+ )
50
+ expect(screen.queryByRole('link', { name: 'Acme' })).toBeNull()
51
+ expect(screen.getByText('Acme')).toBeInTheDocument()
52
+ expect(screen.getByText('Brand')).toBeInTheDocument()
53
+ })
54
+
55
+ it('resolves the legacy breadcrumb + title pair into the same chain', () => {
56
+ renderShell(
57
+ <PageShell breadcrumb="Clients" title="Brand">
58
+ <p>x</p>
59
+ </PageShell>,
60
+ )
61
+ expect(screen.getByText('Clients')).toBeInTheDocument()
62
+ expect(screen.getByText('Brand')).toBeInTheDocument()
63
+ })
64
+
65
+ it('prefers `crumbs` over the legacy props when both are supplied', () => {
66
+ renderShell(
67
+ <PageShell breadcrumb="Legacy" title="Old" crumbs={[{ label: 'New' }]}>
68
+ <p>x</p>
69
+ </PageShell>,
70
+ )
71
+ expect(screen.getByText('New')).toBeInTheDocument()
72
+ expect(screen.queryByText('Legacy')).toBeNull()
73
+ expect(screen.queryByText('Old')).toBeNull()
74
+ })
75
+
76
+ it('renders actions', () => {
77
+ renderShell(
78
+ <PageShell title="Brand" actions={<button type="button">Export</button>}>
79
+ <p>x</p>
80
+ </PageShell>,
81
+ )
82
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
83
+ })
84
+
85
+ it('renders its own sticky header when no host is mounted', () => {
86
+ renderShell(
87
+ <PageShell title="Brand">
88
+ <p>x</p>
89
+ </PageShell>,
90
+ )
91
+ expect(screen.getByRole('banner')).toBeInTheDocument()
92
+ })
93
+
94
+ it('portals the header into the host instead of rendering a second bar', () => {
95
+ renderShell(
96
+ <PageHeaderSlotProvider>
97
+ <PageHeaderHost />
98
+ <PageShell title="Brand" actions={<button type="button">Export</button>}>
99
+ <p>lens content</p>
100
+ </PageShell>
101
+ </PageHeaderSlotProvider>,
102
+ )
103
+ // The crumbs and actions still render exactly once...
104
+ expect(screen.getByText('Brand')).toBeInTheDocument()
105
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
106
+ // ...and PageShell does not add its own <header> on top of the host's.
107
+ expect(screen.getAllByRole('banner')).toHaveLength(1)
108
+ })
109
+
110
+ it('applies the default content padding, overridable by contentClassName', () => {
111
+ const { container, rerender } = renderShell(
112
+ <PageShell title="Brand">
113
+ <p>x</p>
114
+ </PageShell>,
115
+ )
116
+ expect(container.querySelector('.px-7')).not.toBeNull()
117
+
118
+ rerender(
119
+ <MemoryRouter>
120
+ <PageShell title="Brand" contentClassName="p-0">
121
+ <p>x</p>
122
+ </PageShell>
123
+ </MemoryRouter>,
124
+ )
125
+ expect(container.querySelector('.px-7')).toBeNull()
126
+ expect(container.querySelector('.p-0')).not.toBeNull()
127
+ })
128
+ })
@@ -0,0 +1,43 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+
3
+ // Setup runs for BOTH environments. Pure source-scan files opt into the
4
+ // node env with a `// @vitest-environment node` pragma (it skips the
5
+ // ~500ms jsdom init), so every DOM stub below has to be guarded.
6
+ const HAS_DOM = typeof window !== 'undefined'
7
+
8
+ if (HAS_DOM) {
9
+ // jsdom ships neither observer. DropdownMenu measures its content before
10
+ // revealing it, ChipNav scroll-spies with an IntersectionObserver, and
11
+ // several primitives observe container resize. No-op stubs are enough
12
+ // for tests that don't exercise the observers themselves.
13
+ class StubIntersectionObserver {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ takeRecords(): IntersectionObserverEntry[] {
18
+ return []
19
+ }
20
+ root = null
21
+ rootMargin = ''
22
+ thresholds = []
23
+ }
24
+ ;(
25
+ globalThis as unknown as {
26
+ IntersectionObserver: typeof IntersectionObserver
27
+ }
28
+ ).IntersectionObserver =
29
+ StubIntersectionObserver as unknown as typeof IntersectionObserver
30
+
31
+ class StubResizeObserver {
32
+ observe() {}
33
+ unobserve() {}
34
+ disconnect() {}
35
+ }
36
+ ;(
37
+ globalThis as unknown as { ResizeObserver: typeof ResizeObserver }
38
+ ).ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver
39
+
40
+ if (!Element.prototype.scrollIntoView) {
41
+ Element.prototype.scrollIntoView = function () {}
42
+ }
43
+ }
@@ -0,0 +1,193 @@
1
+ // @vitest-environment node
2
+ //
3
+ // Pure source scan — no DOM needed, and jsdom rewrites import.meta.url to
4
+ // an http URL that fileURLToPath rejects.
5
+ /**
6
+ * Static guard for the "type-checks, lints, builds, renders nothing"
7
+ * defect class — CSS that is syntactically fine and semantically void.
8
+ *
9
+ * WHY THIS IS A SOURCE SCAN AND NOT A RENDER TEST
10
+ * ------------------------------------------------
11
+ * The first attempt at this asserted rendered inline styles. It could not
12
+ * work: any value containing `var()` is "pending substitution", so jsdom
13
+ * (and browsers, at parse time) store it verbatim without validating it.
14
+ * `rgb(var(--surface-overlay-soft) / 0.5)` and `rgb(var(--muted))` are
15
+ * indistinguishable through the CSSOM — a render test happily passes on
16
+ * the broken form. The invalidity only appears at computed-value time in
17
+ * a real browser, which is precisely why typecheck, lint, build and the
18
+ * whole test suite were all green while three primitives shipped with no
19
+ * error border and every SortableTable shipped unbanded.
20
+ *
21
+ * So this reads the source instead, and derives its rules from
22
+ * tokens.css rather than hardcoding a token list — a new alpha-carrying
23
+ * or shadow-valued token is covered the day it is added.
24
+ *
25
+ * The two rules:
26
+ *
27
+ * 1. A token whose VALUE is a shadow list ("0 0 0 3px rgb(...)") can
28
+ * never appear inside rgb(). `rgb(var(--ring-error))` expands to
29
+ * `rgb(0 0 0 3px rgb(...))`, which is dropped.
30
+ *
31
+ * 2. A token whose VALUE already carries an alpha ("0 0 0 / 0.03")
32
+ * can never take a second one. `rgb(var(--surface-overlay-soft) / 0.5)`
33
+ * expands to `rgb(0 0 0 / 0.03 / 0.5)`, which is dropped.
34
+ */
35
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
36
+ import { join, relative } from 'node:path'
37
+ import { fileURLToPath } from 'node:url'
38
+ import { describe, expect, it } from 'vitest'
39
+
40
+ const SRC = fileURLToPath(new URL('..', import.meta.url))
41
+
42
+ function sourceFiles(dir: string, acc: string[] = []): string[] {
43
+ for (const entry of readdirSync(dir)) {
44
+ if (entry === 'node_modules' || entry === '__tests__') continue
45
+ const full = join(dir, entry)
46
+ if (statSync(full).isDirectory()) sourceFiles(full, acc)
47
+ else if (/\.(tsx?|css)$/.test(entry) && entry !== 'tokens.css') acc.push(full)
48
+ }
49
+ return acc
50
+ }
51
+
52
+ /** Parse tokens.css into { name -> [values across all theme blocks] }. */
53
+ function readTokenValues(): Map<string, string[]> {
54
+ const css = readFileSync(join(SRC, 'tokens.css'), 'utf8')
55
+ const out = new Map<string, string[]>()
56
+ for (const [, name, value] of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
57
+ const list = out.get(name!) ?? []
58
+ list.push(value!.trim())
59
+ out.set(name!, list)
60
+ }
61
+ return out
62
+ }
63
+
64
+ const TOKENS = readTokenValues()
65
+
66
+ /** A shadow list — has a length unit, so it is not a colour. */
67
+ const isShadowValued = (values: string[]) =>
68
+ values.some((v) => /\b\d+(px|rem|em)\b/.test(v) || v.startsWith('inset '))
69
+
70
+ /** Already carries an alpha, so it cannot take a second one. */
71
+ const isAlphaCarrying = (values: string[]) =>
72
+ values.some((v) => /^[\d\s.]+\/\s*[\d.]+$/.test(v))
73
+
74
+ /**
75
+ * Blank out comments while preserving line numbers, so a violation still
76
+ * reports the line it is on. Without this the scan flags its own
77
+ * explanatory comments — every doc-block that spells out the broken form
78
+ * in order to warn against it.
79
+ *
80
+ * Line comments are only stripped when the line STARTS with `//`, so a
81
+ * `https://` inside a string is left intact.
82
+ */
83
+ function stripComments(src: string): string {
84
+ return src
85
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
86
+ .split('\n')
87
+ .map((line) => (/^\s*\/\//.test(line) ? '' : line))
88
+ .join('\n')
89
+ }
90
+
91
+ const FILES = sourceFiles(SRC)
92
+ const rel = (f: string) => relative(SRC, f)
93
+ const readCode = (f: string) => stripComments(readFileSync(f, 'utf8')).split('\n')
94
+
95
+ describe('token shape', () => {
96
+ it('parsed a plausible token file', () => {
97
+ // Guard the guard: if the parse silently returned nothing, every
98
+ // assertion below would vacuously pass.
99
+ expect(TOKENS.size).toBeGreaterThan(100)
100
+ expect(isShadowValued(TOKENS.get('--ring-error') ?? [])).toBe(true)
101
+ expect(isAlphaCarrying(TOKENS.get('--surface-overlay-soft') ?? [])).toBe(true)
102
+ expect(isShadowValued(TOKENS.get('--accent') ?? [])).toBe(false)
103
+ expect(isAlphaCarrying(TOKENS.get('--accent') ?? [])).toBe(false)
104
+ })
105
+
106
+ it('finds source files to scan', () => {
107
+ expect(FILES.length).toBeGreaterThan(50)
108
+ })
109
+
110
+ it('never wraps a shadow-valued token in rgb()', () => {
111
+ const violations: string[] = []
112
+ for (const file of FILES) {
113
+ readCode(file).forEach((line, i) => {
114
+ for (const [, token] of line.matchAll(/rgb\(\s*var\((--[a-z0-9-]+)\)/g)) {
115
+ if (isShadowValued(TOKENS.get(token!) ?? [])) {
116
+ violations.push(`${rel(file)}:${i + 1} — rgb(var(${token})) is a box-shadow list, not a colour`)
117
+ }
118
+ }
119
+ })
120
+ }
121
+ expect(violations).toEqual([])
122
+ })
123
+
124
+ it('never nests a second alpha on an alpha-carrying token', () => {
125
+ const violations: string[] = []
126
+ for (const file of FILES) {
127
+ readCode(file).forEach((line, i) => {
128
+ for (const [, token] of line.matchAll(
129
+ /rgb\(\s*var\((--[a-z0-9-]+)\)\s*\/\s*[\d.]/g,
130
+ )) {
131
+ if (isAlphaCarrying(TOKENS.get(token!) ?? [])) {
132
+ violations.push(`${rel(file)}:${i + 1} — var(${token}) already carries an alpha`)
133
+ }
134
+ }
135
+ })
136
+ }
137
+ expect(violations).toEqual([])
138
+ })
139
+
140
+ it('never references a token that tokens.css does not define', () => {
141
+ const violations: string[] = []
142
+ for (const file of FILES) {
143
+ readCode(file).forEach((line, i) => {
144
+ for (const [, token] of line.matchAll(/var\((--[a-z0-9-]+)[,)]/g)) {
145
+ // Tailwind's own custom properties are not ours to define.
146
+ if (token!.startsWith('--tw-')) continue
147
+ if (!TOKENS.has(token!)) {
148
+ violations.push(`${rel(file)}:${i + 1} — var(${token}) is not defined in tokens.css`)
149
+ }
150
+ }
151
+ })
152
+ }
153
+ expect(violations).toEqual([])
154
+ })
155
+ })
156
+
157
+ describe('interpolated Tailwind classes', () => {
158
+ it('never builds an arbitrary-value utility from a template literal', () => {
159
+ // Tailwind scans source statically, so `h-[${SIZE}px]` is never
160
+ // generated. It type-checks, lints and builds, and does nothing.
161
+ const violations: string[] = []
162
+ for (const file of FILES) {
163
+ readCode(file).forEach((line, i) => {
164
+ if (/[a-z-]+-\[[^\]]*\$\{/.test(line)) {
165
+ violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
166
+ }
167
+ })
168
+ }
169
+ expect(violations).toEqual([])
170
+ })
171
+ })
172
+
173
+ describe('undefined utility classes', () => {
174
+ // `animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*` ship in
175
+ // tailwindcss-animate / tw-animate-css. Neither is a dependency of this
176
+ // repo, and Tailwind 4 core ships only spin/ping/pulse/bounce — so both
177
+ // of the kit's entrance animations referenced classes that had never
178
+ // been generated and had never run.
179
+ const ANIMATE_PLUGIN_CLASSES =
180
+ /\b(animate-in|animate-out|fade-in(-\d+)?|fade-out(-\d+)?|zoom-in(-\d+)?|zoom-out(-\d+)?|slide-in-from-\w+(-\d+)?|slide-out-to-\w+(-\d+)?)\b/
181
+
182
+ it('does not use tailwindcss-animate classes without the dependency', () => {
183
+ const violations: string[] = []
184
+ for (const file of FILES) {
185
+ readCode(file).forEach((line, i) => {
186
+ if (ANIMATE_PLUGIN_CLASSES.test(line)) {
187
+ violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
188
+ }
189
+ })
190
+ }
191
+ expect(violations).toEqual([])
192
+ })
193
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * AllocationSparkbar — a compact per-period bar strip.
3
+ *
4
+ * New in ADR-123 (Budget Flighting redesign). Renders one bar per period
5
+ * (month), height proportional to the value; a zero/paused period renders
6
+ * short + dashed in a muted tone. Each bar can tint from its own token (e.g. a
7
+ * flight-group color), falling back to `baseColorVar`. Optional single-letter
8
+ * labels sit under each bar.
9
+ *
10
+ * Token discipline: colors come only from public token *names* via
11
+ * `rgb(var(--token) / a)`. No literals. Layout sizes use the spacing scale.
12
+ */
13
+
14
+ export interface AllocationSparkbarProps {
15
+ /** Per-period amounts. A value of 0 is treated as paused (short + dashed). */
16
+ values: number[]
17
+ /** Base token name for normal bars, e.g. `--folder-teal`. Includes `--`. */
18
+ baseColorVar: string
19
+ /** Optional per-period token name (e.g. a flight-group color). `null`/
20
+ * `undefined` falls back to `baseColorVar`. Length should match `values`. */
21
+ colorVars?: (string | null | undefined)[]
22
+ /** Optional short labels under each bar (e.g. `['J','F','M']`). */
23
+ labels?: string[]
24
+ /** Optional per-bar tooltip text (native `title`). */
25
+ titles?: string[]
26
+ /** Strip height in px. Default 36. */
27
+ height?: number
28
+ className?: string
29
+ }
30
+
31
+ export function AllocationSparkbar({
32
+ values,
33
+ baseColorVar,
34
+ colorVars,
35
+ labels,
36
+ titles,
37
+ height = 36,
38
+ className,
39
+ }: AllocationSparkbarProps) {
40
+ const max = Math.max(...values, 1)
41
+ return (
42
+ <div
43
+ className={className}
44
+ style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'flex-end', height }}
45
+ >
46
+ {values.map((value, i) => {
47
+ const paused = value === 0
48
+ const barH = paused ? 4 : 8 + (value / max) * (height - 12)
49
+ const colorVar = colorVars?.[i] ?? baseColorVar
50
+ return (
51
+ <div
52
+ key={i}
53
+ title={titles?.[i]}
54
+ style={{
55
+ flex: 1,
56
+ display: 'flex',
57
+ flexDirection: 'column',
58
+ alignItems: 'center',
59
+ gap: 'var(--space-1)',
60
+ minWidth: 0,
61
+ }}
62
+ >
63
+ <div
64
+ style={{
65
+ width: '100%',
66
+ height: barH,
67
+ borderRadius: 'var(--radius-xs)',
68
+ background: paused
69
+ ? 'rgb(var(--text-muted) / 0.18)'
70
+ : `rgb(var(${colorVar}) / 0.67)`,
71
+ border: paused
72
+ ? '1px dashed rgb(var(--text-muted) / 0.4)'
73
+ : 'none',
74
+ transition: 'background var(--dur-fast, 120ms)',
75
+ }}
76
+ />
77
+ {labels?.[i] !== undefined && (
78
+ <span
79
+ className="text-[9.5px] font-semibold tracking-[0.02em]"
80
+ style={{ color: 'rgb(var(--text-muted))' }}
81
+ >
82
+ {labels[i]}
83
+ </span>
84
+ )}
85
+ </div>
86
+ )
87
+ })}
88
+ </div>
89
+ )
90
+ }
package/src/card.tsx CHANGED
@@ -203,7 +203,7 @@ function MetaItem({
203
203
  {icon}
204
204
  </span>
205
205
  )}
206
- <span className="truncate tnum">{children}</span>
206
+ <span className="truncate tabular-nums">{children}</span>
207
207
  </div>
208
208
  )
209
209
  }