basalt-ui 1.9.0 → 1.11.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 (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. package/src/tokens/build-fonts-css.test.ts +3 -2
package/src/cli/index.ts CHANGED
@@ -165,6 +165,25 @@ export type BasaltConfig = {
165
165
  * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
166
166
  */
167
167
  exemptRules?: Partial<Record<GuardKind, string[]>>
168
+ /**
169
+ * Declares an intentional `ai` package major-version skew across workspace packages, exempting
170
+ * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
171
+ * because the whole point of this key is that the pairing is written down, not just switched off.
172
+ * A written declaration is the pin `basalt/ai-sdk-major`'s own comment notes is otherwise missing.
173
+ *
174
+ * Semantics: absent (or present but not a non-empty string) → the skew still hard-fails exactly as
175
+ * without this key. A non-empty string with a skew present → `doctor` passes, echoing both the
176
+ * skew and this reason. A non-empty string with NO skew present → `doctor` warns that the
177
+ * exemption is stale and can be deleted (an exemption nobody re-checks is how a real, later skew
178
+ * slips through unnoticed).
179
+ *
180
+ * @example
181
+ * // apps/api streams on ai@5; apps/dashboard parses on ai@7; a producer-side TransformStream
182
+ * // neutralizes the one enum value that differs between the two majors.
183
+ * { aiMajorSkewReason: 'apps/api pinned to ai@5, apps/dashboard on ai@7 — the skew is neutralized '
184
+ * + 'by a producer-side TransformStream in apps/api/src/stream-transform.ts' }
185
+ */
186
+ aiMajorSkewReason?: string
168
187
  }
169
188
 
170
189
  const DEFAULT_ROOT = 'src'
@@ -1560,6 +1579,202 @@ type DoctorResult = {
1560
1579
  warnings: number
1561
1580
  }
1562
1581
 
1582
+ type WorkspacePackage = { readonly name: string; readonly dir: string }
1583
+
1584
+ /** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
1585
+ * field was present at all (distinguishes "nothing to compare" from "not a workspace root"). */
1586
+ type WorkspaceWalk = {
1587
+ readonly packages: readonly WorkspacePackage[]
1588
+ readonly hasWorkspacesField: boolean
1589
+ }
1590
+
1591
+ /**
1592
+ * Every subdirectory of `base` (name only), or `[]` if `base` doesn't exist / isn't a directory.
1593
+ * `node_modules` is always excluded — a wildcard workspaces pattern must never descend into a
1594
+ * dependency tree. `skipDotDirs` additionally excludes dot-directories; it is only set by the
1595
+ * `*`/`**` wildcard descent in {@link expandPatternSegments}, never for an explicitly named
1596
+ * literal segment, so a pattern like `.internal/*` still resolves.
1597
+ */
1598
+ function subdirNames(base: string, options: { skipDotDirs?: boolean } = {}): string[] {
1599
+ let entries: string[]
1600
+ try {
1601
+ entries = readdirSync(base)
1602
+ } catch {
1603
+ return []
1604
+ }
1605
+ return entries.filter((name) => {
1606
+ if (name === 'node_modules') return false
1607
+ if (options.skipDotDirs && name.startsWith('.')) return false
1608
+ try {
1609
+ return statSync(resolve(base, name)).isDirectory()
1610
+ } catch {
1611
+ return false
1612
+ }
1613
+ })
1614
+ }
1615
+
1616
+ /**
1617
+ * Expands one glob PATTERN's segments against the filesystem, starting from `baseDir`. Supports
1618
+ * three segment kinds: a literal name, a single `*` wildcard (any one directory), and a `**`
1619
+ * wildcard (zero or more directory levels, i.e. arbitrary depth) — covering a trailing single
1620
+ * wildcard, an interior wildcard between literal segments, and a trailing double-wildcard alike,
1621
+ * not just one trailing single-wildcard segment.
1622
+ */
1623
+ function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
1624
+ if (segments.length === 0) return [baseDir]
1625
+ const [head, ...rest] = segments
1626
+
1627
+ if (head === '**') {
1628
+ // Zero levels: `**` consumes nothing and the rest of the pattern matches right here.
1629
+ const matches = expandPatternSegments(baseDir, rest)
1630
+ // One-or-more levels: descend into every subdirectory, `**` still active.
1631
+ for (const name of subdirNames(baseDir, { skipDotDirs: true })) {
1632
+ matches.push(...expandPatternSegments(resolve(baseDir, name), segments))
1633
+ }
1634
+ return matches
1635
+ }
1636
+
1637
+ const names =
1638
+ head === '*'
1639
+ ? subdirNames(baseDir, { skipDotDirs: true })
1640
+ : subdirNames(baseDir).filter((n) => n === head)
1641
+ return names.flatMap((name) => expandPatternSegments(resolve(baseDir, name), rest))
1642
+ }
1643
+
1644
+ /**
1645
+ * Expands a root `package.json`'s `workspaces` patterns into concrete package directories.
1646
+ * Supports literal segments, a single-wildcard segment, and a double-wildcard segment, both
1647
+ * interior and trailing (e.g. `packages` followed by a double wildcard, or an interior single
1648
+ * wildcard between two literal segments) — not just one trailing single-wildcard segment. A
1649
+ * candidate directory that has no `package.json` of its own is skipped, not reported — an empty
1650
+ * scaffold directory is not a workspace package. Also honours npm/bun/yarn `!`-prefixed exclusion
1651
+ * entries (e.g. `["packages/*", "!packages/legacy"]`) — both the included and excluded patterns
1652
+ * are expanded through the same machinery, then the excluded set is subtracted.
1653
+ */
1654
+ function expandWorkspaceGlobs(cwd: string, patterns: readonly string[]): string[] {
1655
+ const expand = (pattern: string): string[] =>
1656
+ expandPatternSegments(
1657
+ cwd,
1658
+ pattern.split('/').filter((s) => s.length > 0),
1659
+ )
1660
+
1661
+ const excluded = new Set(
1662
+ patterns
1663
+ .filter((pattern) => pattern.startsWith('!'))
1664
+ .flatMap((pattern) => expand(pattern.replace(/^!+/, ''))),
1665
+ )
1666
+
1667
+ const dirs: string[] = []
1668
+ for (const pattern of patterns) {
1669
+ if (pattern.startsWith('!')) continue
1670
+ for (const abs of expand(pattern)) {
1671
+ if (excluded.has(abs)) continue
1672
+ if (existsSync(resolve(abs, 'package.json'))) dirs.push(abs)
1673
+ }
1674
+ }
1675
+ return dirs
1676
+ }
1677
+
1678
+ /**
1679
+ * Every workspace package declared by `cwd`'s root `package.json` (npm/bun/yarn `workspaces`
1680
+ * field, array or `{ packages: [...] }` object form), named + directory-resolved — PLUS the root
1681
+ * manifest itself, labelled `"{name} (root)"`. The root is walked because it commonly carries a
1682
+ * hoisted `ai` dev dependency that can itself skew against a workspace package's own major — that
1683
+ * is the producer/consumer shape the whole check exists to catch, and it is invisible if only the
1684
+ * expanded workspace dirs are inspected. `hasWorkspacesField` is `false` (and `packages` empty)
1685
+ * when `cwd` has no `package.json`, no `workspaces` field, or the field is a shape this doesn't
1686
+ * recognize — a repo-wide walk is a bonus check, not something that should ever throw doctor over
1687
+ * an unrecognized `workspaces` shape.
1688
+ */
1689
+ function collectWorkspacePackages(cwd: string): WorkspaceWalk {
1690
+ const raw = readIfExists(resolve(cwd, 'package.json'))
1691
+ if (raw === null) return { packages: [], hasWorkspacesField: false }
1692
+
1693
+ let parsed: { workspaces?: unknown; name?: string }
1694
+ try {
1695
+ parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
1696
+ } catch {
1697
+ return { packages: [], hasWorkspacesField: false }
1698
+ }
1699
+
1700
+ const patterns = isStringArray(parsed.workspaces)
1701
+ ? parsed.workspaces
1702
+ : parsed.workspaces !== null &&
1703
+ typeof parsed.workspaces === 'object' &&
1704
+ isStringArray((parsed.workspaces as { packages?: unknown }).packages)
1705
+ ? (parsed.workspaces as { packages: string[] }).packages
1706
+ : null
1707
+ if (patterns === null) return { packages: [], hasWorkspacesField: false }
1708
+
1709
+ const rootName = parsed.name && parsed.name.length > 0 ? `${parsed.name} (root)` : '(root)'
1710
+ const workspacePackages = expandWorkspaceGlobs(cwd, patterns).map((dir) => ({
1711
+ name: readPackageName(dir),
1712
+ dir,
1713
+ }))
1714
+
1715
+ return {
1716
+ packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
1717
+ hasWorkspacesField: true,
1718
+ }
1719
+ }
1720
+
1721
+ /** Narrows to a string array — shared by `collectWorkspacePackages`'s two `workspaces` shapes. */
1722
+ function isStringArray(value: unknown): value is string[] {
1723
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
1724
+ }
1725
+
1726
+ /**
1727
+ * First run of digits in a semver range string (`^7.0.15` → `7`), or null if there is none.
1728
+ *
1729
+ * Duplicated verbatim in `configs/oxlint-plugin.js` — that file must stay import-free from this
1730
+ * package (it loads via `jsPlugins` out of a consumer's node_modules, before this package's own
1731
+ * code is necessarily resolvable), so the duplication is structural, not an oversight. Keep both
1732
+ * copies in sync by hand; a future parsing fix (pre-release suffixes, say) must land in both.
1733
+ */
1734
+ function majorOf(range: string | undefined): number | null {
1735
+ if (typeof range !== 'string') return null
1736
+ const match = range.match(/\d+/)
1737
+ return match === null ? null : Number(match[0])
1738
+ }
1739
+
1740
+ /** The `ai` package's declared major at `dir`'s own `package.json`, or null if undeclared/unreadable. */
1741
+ function aiMajorAt(dir: string): number | null {
1742
+ const raw = readIfExists(resolve(dir, 'package.json'))
1743
+ if (raw === null) return null
1744
+ try {
1745
+ const pkg = JSON.parse(raw) as {
1746
+ dependencies?: Record<string, string>
1747
+ devDependencies?: Record<string, string>
1748
+ peerDependencies?: Record<string, string>
1749
+ }
1750
+ return majorOf(
1751
+ pkg.dependencies?.['ai'] ?? pkg.devDependencies?.['ai'] ?? pkg.peerDependencies?.['ai'],
1752
+ )
1753
+ } catch {
1754
+ return null
1755
+ }
1756
+ }
1757
+
1758
+ /**
1759
+ * Splits `basalt.aiMajorSkewReason` into a validated reason (or null) plus whether the key was
1760
+ * present at all but failed validation. A present-but-invalid value (anything other than a
1761
+ * non-empty string — including a bare `true`) is treated as absent for the pass/fail decision, same
1762
+ * as a missing key, but doctor's failure message still calls out that the key exists and is
1763
+ * malformed rather than silently falling back to the plain "no exemption" message — a forgotten key
1764
+ * and a broken one are different mistakes and deserve different guidance.
1765
+ */
1766
+ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
1767
+ reason: string | null
1768
+ presentButInvalid: boolean
1769
+ } {
1770
+ const raw = cfg.aiMajorSkewReason
1771
+ if (raw === undefined) return { reason: null, presentButInvalid: false }
1772
+ if (typeof raw === 'string' && raw.trim().length > 0) {
1773
+ return { reason: raw, presentButInvalid: false }
1774
+ }
1775
+ return { reason: null, presentButInvalid: true }
1776
+ }
1777
+
1563
1778
  /**
1564
1779
  * Check a consumer repo's basalt integration and print a pass/warn report.
1565
1780
  *
@@ -1570,6 +1785,17 @@ type DoctorResult = {
1570
1785
  *
1571
1786
  * Hard failures (exit non-zero):
1572
1787
  * 1. .basalt/manifest.json exists (init was run).
1788
+ * 6. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
1789
+ * major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
1790
+ * a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
1791
+ * ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
1792
+ * package streaming on `ai@5`, another parsing it on `ai@7`) only a repo-wide manifest walk
1793
+ * can see. Skipped entirely when `cwd` has no `workspaces` field or none of its packages
1794
+ * declare `ai`. A detected skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
1795
+ * reason string, not a bare `true`) in the consumer's package.json — an intentional
1796
+ * producer/consumer split neutralized by a transform is a locked decision, not a defect, and
1797
+ * the written reason IS the pin that a permanently-failing guard would otherwise lack. Missing
1798
+ * or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
1573
1799
  *
1574
1800
  * Warnings (non-fatal):
1575
1801
  * 2. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
@@ -1581,10 +1807,14 @@ type DoctorResult = {
1581
1807
  * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
1582
1808
  * 5. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
1583
1809
  * a public/ dir exists at all — skipped otherwise).
1810
+ * 7. A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the exemption is
1811
+ * stale and can be deleted; an exemption nobody revisits is how a real, later skew slips
1812
+ * through unnoticed.
1584
1813
  *
1585
1814
  * Returns the exit code: 0 = all good, 1 = one or more hard failures.
1586
1815
  */
1587
1816
  export function doctor(cwd: string = process.cwd()): number {
1817
+ const cfg = readBasaltConfig(cwd)
1588
1818
  const result: DoctorResult = { hardFailures: 0, warnings: 0 }
1589
1819
  const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
1590
1820
 
@@ -1728,6 +1958,61 @@ export function doctor(cwd: string = process.cwd()): number {
1728
1958
  }
1729
1959
  }
1730
1960
 
1961
+ // ── Hard check 6: ai package major version parity across workspace packages ─
1962
+ // basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
1963
+ // run scoped to one workspace package only ever sees that package's own `ai` major and is
1964
+ // perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
1965
+ // stream apps/dashboard's ai@7 client can't parse) and only a repo-wide manifest walk sees it —
1966
+ // this is that walk. Hard failure, not a warning: a skewed pair throws at runtime, it doesn't
1967
+ // just look different. `basalt.aiMajorSkewReason` exempts an INTENTIONAL skew (e.g. a
1968
+ // producer-side TransformStream neutralizing the divergent enum value) — see the BasaltConfig
1969
+ // JSDoc for its exact semantics; a stale declaration (skew resolved, reason still present) warns
1970
+ // instead of passing silently.
1971
+ const workspaceWalk = collectWorkspacePackages(cwd)
1972
+ const aiMajors = workspaceWalk.packages
1973
+ .map((pkg) => ({ name: pkg.name, major: aiMajorAt(pkg.dir) }))
1974
+ .filter((entry): entry is { name: string; major: number } => entry.major !== null)
1975
+ const { reason: aiMajorSkewReason, presentButInvalid: aiMajorSkewReasonInvalid } =
1976
+ resolveAiMajorSkewReason(cfg)
1977
+ if (aiMajors.length > 0) {
1978
+ const distinctMajors = new Set(aiMajors.map((entry) => entry.major))
1979
+ if (distinctMajors.size > 1) {
1980
+ const summary = aiMajors.map((entry) => `${entry.name}@ai${entry.major}`).join(', ')
1981
+ if (aiMajorSkewReason !== null) {
1982
+ pass(
1983
+ `ai package major version mismatch across workspace packages: ${summary} — exempted via ` +
1984
+ `basalt.aiMajorSkewReason: "${aiMajorSkewReason}"`,
1985
+ )
1986
+ } else if (aiMajorSkewReasonInvalid) {
1987
+ fail(
1988
+ `ai package major version mismatch across workspace packages: ${summary} — ` +
1989
+ 'basalt.aiMajorSkewReason is present but is not a non-empty string (a bare `true` is ' +
1990
+ 'not accepted); the exemption must carry a written reason, e.g. ' +
1991
+ '`aiMajorSkewReason: "apps/api on ai@5, apps/dashboard on ai@7, neutralized by a ' +
1992
+ 'producer-side TransformStream"`.',
1993
+ )
1994
+ } else {
1995
+ fail(`ai package major version mismatch across workspace packages: ${summary}`)
1996
+ }
1997
+ } else {
1998
+ const [major] = distinctMajors
1999
+ if (aiMajorSkewReason !== null) {
2000
+ warn(
2001
+ `basalt.aiMajorSkewReason ("${aiMajorSkewReason}") is declared but the ai package major ` +
2002
+ `already matches across all ${aiMajors.length} workspace package(s) declaring it ` +
2003
+ `(ai@${major}) — the exemption is no longer needed and can be deleted.`,
2004
+ )
2005
+ } else {
2006
+ pass(
2007
+ `ai package major matches across ${aiMajors.length} workspace package(s) declaring it (ai@${major})`,
2008
+ )
2009
+ }
2010
+ }
2011
+ } else if (workspaceWalk.hasWorkspacesField) {
2012
+ // A hard check that can silently not run is worse than one that is absent — say so.
2013
+ pass('ai-major-parity: no workspace package declares the ai dependency — nothing to compare')
2014
+ }
2015
+
1731
2016
  // ── Report ──────────────────────────────────────────────────────────────────
1732
2017
  lines.push('')
1733
2018
  if (result.hardFailures === 0 && result.warnings === 0) {
@@ -13,9 +13,11 @@
13
13
  * leaving the prop off, so the absence case asserts no rail and no verdict text at all — which a
14
14
  * future `tone = tone ?? 'good'` style default would immediately fail.
15
15
  *
16
- * No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
17
- * `renderToStaticMarkup` inside a real `MantineProvider` is enough here, since the rail is an inline
18
- * style on server-rendered markup.
16
+ * A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
17
+ * `theme/use-basalt-spacing.test.tsx`'s doc) — `renderToStaticMarkup` inside a real `MantineProvider`
18
+ * is used deliberately here instead, since the rail is an inline style on server-rendered markup.
19
+ * Converting to the DOM harness would only be worth it if a future assertion here needed live DOM
20
+ * behavior (e.g. computed styles, focus/hover interaction) rather than the static markup string.
19
21
  */
20
22
  import { MantineProvider } from '@mantine/core'
21
23
  import { describe, expect, test } from 'bun:test'
@@ -209,11 +209,15 @@ describe('mantine-shade-index', () => {
209
209
  })
210
210
 
211
211
  // The grace-minor doctrine (package CLAUDE.md): a kind that rejects previously-passing code
212
- // lands as `warn` for one minor. Deleting its GRACE_PERIOD_KINDS entry flips this — scheduled
213
- // for 1.9.0, deferred one minor because 1.8.0 shipped the same day as 1.7.0 (see the entry).
214
- it('lands as a warning, not an error, for its grace minor', () => {
212
+ // lands as `warn` for one minor, then its GRACE_PERIOD_KINDS entry is deleted and it becomes an
213
+ // error. This one ran its grace across FOUR minors — introduced 1.7.0, then deferred by 1.8.0
214
+ // (shipped the same day as 1.7.0), 1.9.0 (carried the chart-layer batch the same consumer was
215
+ // waiting on) and 1.10.0 (shipped without the promotion at all). Promoted in 1.11.0, verified
216
+ // against the only consumer first: argo's `check-theme` reports zero violations of any kind, so
217
+ // the promotion breaks nothing that was passing.
218
+ it('is an error now that its grace period has ended', () => {
215
219
  const f = find(`<Text c="yellow.7" />`)
216
- expect(f.find((x) => x.kind === 'mantine-shade-index')?.severity).toBe('warn')
220
+ expect(f.find((x) => x.kind === 'mantine-shade-index')?.severity).toBe('error')
217
221
  })
218
222
  })
219
223
 
@@ -296,9 +296,7 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
296
296
  * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
297
297
  * }
298
298
  */
299
- const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
300
- 'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.10.0',
301
- }
299
+ const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {}
302
300
 
303
301
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
304
302
  function severityOf(kind: GuardKind, cfg: GuardConfig): GuardSeverity {
package/src/index.ts CHANGED
@@ -118,6 +118,7 @@ export {
118
118
  Composer,
119
119
  threadPartRenderers,
120
120
  ThreadTranscript,
121
+ ToolChip,
121
122
  } from './agent-chat'
122
123
  export type {
123
124
  ThreadWorkspaceProps,
@@ -126,6 +127,7 @@ export type {
126
127
  ThreadDetailPanelProps,
127
128
  ComposerProps,
128
129
  ThreadTranscriptProps,
130
+ ToolChipProps,
129
131
  } from './agent-chat'
130
132
  export { createThreadsStore, heuristicOutcome, useAgentThreadRuns } from './agent'
131
133
  export type {
@@ -10,11 +10,12 @@
10
10
  * The `BasaltBridge density wiring` block below covers the layer `composeInjectedCss` alone can't:
11
11
  * that `BasaltBridge` (not just the pure `composeInjectedCss` helper) actually reads
12
12
  * `theme.other.basaltDensity` off the RUNNING theme and feeds it through `buildDensityCss` into the
13
- * injected `<style>`, end to end from `createBasaltTheme(undefined, { density })`. No
14
- * `@testing-library/react`/jsdom is configured in this repo (see `tokens/build-fonts-css.test.ts`'s
15
- * doc for that same limitation elsewhere) — `react-dom/server`'s `renderToStaticMarkup` renders the
16
- * real `BasaltProvider` tree without a DOM, which is enough since the injected `<style>` is plain
17
- * text content, not something that needs a live DOM to assert on.
13
+ * injected `<style>`, end to end from `createBasaltTheme(undefined, { density })`. A DOM harness now
14
+ * exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`) — this suite still uses
15
+ * `react-dom/server`'s `renderToStaticMarkup` deliberately, since the injected `<style>` is plain
16
+ * text content on server-rendered markup, not something that needs a live DOM to assert on.
17
+ * Converting this to mount through the DOM harness would only be worth it if a future assertion here
18
+ * needed live DOM behavior (event handling, layout, effects) rather than the static markup string.
18
19
  */
19
20
  import { describe, expect, test } from 'bun:test'
20
21
  import { renderToStaticMarkup } from 'react-dom/server'
package/src/surfaces.ts CHANGED
@@ -362,7 +362,7 @@ export const SURFACES = {
362
362
  skill: ['basalt-app'],
363
363
  guardKinds: [],
364
364
  description:
365
- 'Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, PartList, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free)',
365
+ 'Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, isResumable/ResumableAgentTransport (stream-resumption seam), PartList, coalesceParts, the ForeignPart/definePartRenderers/narrowAgentPart open part-registry seam, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free)',
366
366
  optionalPeers: ['ai', 'use-stick-to-bottom'],
367
367
  globs: {
368
368
  shipped: [],
@@ -371,6 +371,28 @@ export const SURFACES = {
371
371
  // @visx/* ban dropped — `basalt/visx-boundary` now bans it universally outside charts.
372
372
  forbiddenImports: [...MANTINE_BANS],
373
373
  },
374
+ './agent-chat': {
375
+ kind: 'doctrine',
376
+ layer: 'mantine-coupled',
377
+ rule: 'agent',
378
+ skill: ['basalt-app'],
379
+ guardKinds: [],
380
+ description:
381
+ 'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). remend and motion are required, not optional — ThreadTranscript imports content/markdown.tsx (remend eagerly), and ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without both installed even though peerDependenciesMeta marks them optional (npm has no per-subpath optionality).',
382
+ optionalPeers: [
383
+ 'ai',
384
+ 'motion',
385
+ 'remend',
386
+ 'use-stick-to-bottom',
387
+ 'react-markdown',
388
+ 'remark-gfm',
389
+ 'shiki',
390
+ '@shikijs/langs',
391
+ '@shikijs/themes',
392
+ 'beautiful-mermaid',
393
+ ],
394
+ forbiddenImports: [],
395
+ },
374
396
  './content': {
375
397
  kind: 'doctrine',
376
398
  layer: 'mantine-coupled',
@@ -6,10 +6,13 @@
6
6
  * with every other density test still green, since those all exercise `deriveSpacing`/
7
7
  * `createBasaltTheme` directly, never this hook.
8
8
  *
9
- * No `@testing-library/react`/jsdom is configured in this repo (see `tokens/build-fonts-css.test.ts`'s
10
- * doc for that same limitation elsewhere) — `react-dom/server`'s `renderToStaticMarkup` is enough to
11
- * invoke a hook inside a REAL `MantineProvider` tree without a DOM, so these assertions exercise the
12
- * actual `useMantineTheme()` read, not a re-implementation of it.
9
+ * A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
10
+ * `tokens/build-fonts-css.test.ts`'s doc for that same note elsewhere) — this suite still uses
11
+ * `react-dom/server`'s `renderToStaticMarkup` deliberately: it's enough to invoke a hook inside a
12
+ * REAL `MantineProvider` tree without a DOM, so these assertions exercise the actual
13
+ * `useMantineTheme()` read, not a re-implementation of it. Converting to the DOM harness would only
14
+ * be worth it if a future assertion here needed the hook to react to a live DOM event (e.g. a
15
+ * `ResizeObserver`-driven remeasure) rather than a single render-time read.
13
16
  */
14
17
  import { MantineProvider } from '@mantine/core'
15
18
  import type { MantineProviderProps } from '@mantine/core'
@@ -2,8 +2,9 @@
2
2
  * `buildFontsCss` — the pure CSS-building half of the `{ fonts }` option (see
3
3
  * `../theme/fonts-option.test.ts` for the `theme.other.basaltFonts` round-trip this consumes).
4
4
  * `BasaltBridge` calls this directly to append `--basalt-font-*` declarations to the same injected
5
- * `<style>` as the palette CSS; no DOM/render harness is configured in this package, so the pure
6
- * function is the testable seam.
5
+ * `<style>` as the palette CSS. A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the
6
+ * root `bunfig.toml`), but this pure function needs no render at all — it's the testable seam on
7
+ * its own, so this suite calls it directly rather than mounting anything.
7
8
  */
8
9
  import { describe, expect, test } from 'bun:test'
9
10
  import { buildFontsCss } from './index'