elevation-styles.client.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /**
  2. * Elevation stylesheet contract, asserted against the CSS text on disk:
  3. * gradient-shadow-text.css composes the elevation tokens from a rebindable
  4. * 0.5px hairline stroke plus soft layers, and no package rule pairs an
  5. * lv/elevation box-shadow with a neutral-border-token border — elevated
  6. * surfaces draw their neutral stroke inside the elevation shadow (border: 0),
  7. * never as a layout-consuming border beside it. State-colored borders (for
  8. * example the warn approval panels) stay real borders and are out of scope.
  9. */
  10. import { readFileSync } from 'node:fs'
  11. import { basename } from 'node:path'
  12. import { fileURLToPath } from 'node:url'
  13. import { describe, expect, it } from 'vitest'
  14. import { packageStylesheets, parseRules } from './stylesheet-scan.ts'
  15. /** Stroke-color indirection components may rebind per surface or state. */
  16. const STROKE_COLOR = '--dsw-elevation-stroke-color'
  17. /** Shadow-token references that mark a rule as an elevated surface. */
  18. const ELEVATED_SHADOW = /--dsw-(?:shadow-lv|elevation-)/
  19. /** Neutral border tokens; the state palette (--dsw-alias-state-*) stays allowed. */
  20. const NEUTRAL_BORDER = /--dsw-alias-border-/
  21. const sheetCss = readFileSync(
  22. fileURLToPath(new URL('../src/styles/gradient-shadow-text.css', import.meta.url)), 'utf8')
  23. describe('elevation tokens', () => {
  24. const rules = parseRules(sheetCss)
  25. const bodyOnly = new Map(rules
  26. .filter(rule => rule.selectors.length === 1 && rule.selectors[0] === 'body')
  27. .flatMap(rule => rule.declarations))
  28. const perElement = new Map(rules
  29. .filter(rule => rule.selectors.includes('body *'))
  30. .flatMap(rule => rule.declarations))
  31. it('defaults the stroke color on body alone, so a surface rebind inherits', () => {
  32. // Declared per element, `body *` would beat inheritance on every
  33. // descendant and a surface's rebind could not reach the box that carries
  34. // the shadow; declared on body alone, the rebind inherits down.
  35. expect(bodyOnly.get(STROKE_COLOR)).toBe('var(--dsw-alias-border-l4)')
  36. expect(perElement.has(STROKE_COLOR)).toBe(false)
  37. })
  38. it('declares the derived values per element, so a stroke rebind takes effect', () => {
  39. // A custom property computes with var() already substituted, and
  40. // descendants inherit that computed value: derived tokens declared only on
  41. // body would bake in body's stroke color, making every
  42. // --dsw-elevation-stroke-color rebind a no-op. Per-element declarations
  43. // re-substitute against the color each element sees (the same contract
  44. // scrollbar.css states for --dsh-scrollbar-thumb).
  45. expect(perElement.get('--dsw-elevation-stroke')).toBe(`0 0 0 0.5px var(${STROKE_COLOR})`)
  46. for (const name of ['--dsw-elevation-panel', '--dsw-elevation-prominent', '--dsw-elevation-soft']) {
  47. expect(perElement.get(name), name).toMatch(/^var\(--dsw-elevation-stroke\), 0 /)
  48. expect(bodyOnly.has(name), name).toBe(false)
  49. }
  50. })
  51. })
  52. /**
  53. * Rules pairing an lv/elevation box-shadow with a neutral-border-token border.
  54. * @param css - stylesheet text.
  55. * @returns the offending selectors, in source order.
  56. */
  57. function neutralBordersBesideElevation(css: string): string[] {
  58. return parseRules(css)
  59. .filter(rule => rule.declarations
  60. .some(([property, value]) => property === 'box-shadow' && ELEVATED_SHADOW.test(value)))
  61. .filter(rule => rule.declarations.some(([property, value]) =>
  62. property.startsWith('border') && !property.startsWith('border-radius') && NEUTRAL_BORDER.test(value)))
  63. .map(rule => rule.selectors.join(', '))
  64. }
  65. describe('elevated surfaces carry no neutral border', () => {
  66. it('rejects a rule that pairs the shadow with a neutral border', () => {
  67. expect(neutralBordersBesideElevation(
  68. '.a { box-shadow: var(--dsw-elevation-panel); border: 0.5px solid var(--dsw-alias-border-l2); }',
  69. )).toEqual(['.a'])
  70. expect(neutralBordersBesideElevation(
  71. '.a { box-shadow: var(--dsw-elevation-panel); border: 0; }',
  72. )).toEqual([])
  73. })
  74. it('never pairs an lv/elevation shadow with a neutral border token under packages/', () => {
  75. // A 1px border beside the elevation stroke double-draws the outline and
  76. // shifts layout by the border width; the hairline belongs to the shadow.
  77. const paired = packageStylesheets().flatMap(file =>
  78. neutralBordersBesideElevation(readFileSync(file, 'utf8'))
  79. .map(selectors => `${file} ${selectors}`))
  80. expect(paired).toEqual([])
  81. })
  82. })
  83. /** Border properties that carry a width in their shorthand. */
  84. const BORDER_EDGE = /^border(?:-top|-bottom|-left|-right)?$/
  85. /**
  86. * Solid neutral-token borders wider than the 0.5px hairline. The width test is
  87. * lexical and order-sensitive: `border: solid 0.5px …` would be reported (a
  88. * loud false positive to normalize), while split `border-width`/`border-color`
  89. * declarations fall outside BORDER_EDGE and are not seen; no sheet under test
  90. * writes either form.
  91. * @param css - stylesheet text.
  92. * @param exempt - `<selector> <property>` pairs allowed to keep their width.
  93. * @returns the offending `<selectors> <property>: <value>` lines, in source order.
  94. */
  95. function wideNeutralBorders(css: string, exempt: Set<string> = new Set()): string[] {
  96. const wide: string[] = []
  97. for (const rule of parseRules(css)) {
  98. for (const [property, value] of rule.declarations) {
  99. if (!BORDER_EDGE.test(property)) continue
  100. if (!value.includes('solid') || !NEUTRAL_BORDER.test(value)) continue
  101. if (value.startsWith('0.5px ')) continue
  102. if (rule.selectors.some(selector => exempt.has(selector))) continue
  103. wide.push(`${rule.selectors.join(', ')} ${property}: ${value}`)
  104. }
  105. }
  106. return wide
  107. }
  108. /**
  109. * Filled divider lines (a border-token background on a 1px-tall or 1px-wide
  110. * box) that keep the pre-hairline weight.
  111. * @param css - stylesheet text.
  112. * @returns the offending `<selectors> <property>: <value>` lines, in source order.
  113. */
  114. function wideFilledDividers(css: string): string[] {
  115. const wide: string[] = []
  116. for (const rule of parseRules(css)) {
  117. const paintsLine = rule.declarations.some(([property, value]) =>
  118. (property === 'background' || property === 'background-color') && NEUTRAL_BORDER.test(value))
  119. if (!paintsLine) continue
  120. for (const [property, value] of rule.declarations) {
  121. if ((property === 'height' || property === 'width') && value === '1px') {
  122. wide.push(`${rule.selectors.join(', ')} ${property}: ${value}`)
  123. }
  124. }
  125. }
  126. return wide
  127. }
  128. describe('neutral solid borders are hairlines', () => {
  129. /**
  130. * Spinner ring tracks, keyed `<basename> <selector>`: the border is the
  131. * drawn graphic (a rotating ring), not an outline, so it keeps its width.
  132. */
  133. const RING_TRACKS = new Set([
  134. 'boot-page.module.css .spinner',
  135. 'TrajectoryTable.module.css .historyLoadingSpinner',
  136. ])
  137. it('rejects a wide neutral border and a wide filled divider', () => {
  138. expect(wideNeutralBorders('.a { border: 1px solid var(--dsw-alias-border-l2); }'))
  139. .toEqual(['.a border: 1px solid var(--dsw-alias-border-l2)'])
  140. expect(wideNeutralBorders('.a { border: 0.5px solid var(--dsw-alias-border-l2); }')).toEqual([])
  141. expect(wideFilledDividers('.a { background: var(--dsw-alias-border-l2); height: 1px; }'))
  142. .toEqual(['.a height: 1px'])
  143. expect(wideFilledDividers('.a { background: var(--dsw-alias-border-l2); height: 0.5px; }')).toEqual([])
  144. })
  145. it('draws every solid neutral-token border at 0.5px under packages/', () => {
  146. // Buttons, inputs, cards, and separators share the hairline weight;
  147. // dashed affordances and state-colored borders are out of scope.
  148. const wide = packageStylesheets().flatMap((file) => {
  149. const base = basename(file)
  150. const exempt = new Set([...RING_TRACKS]
  151. .filter(track => track.startsWith(`${base} `))
  152. .map(track => track.slice(base.length + 1)))
  153. return wideNeutralBorders(readFileSync(file, 'utf8'), exempt)
  154. .map(line => `${file} ${line}`)
  155. })
  156. expect(wide).toEqual([])
  157. })
  158. it('draws every filled divider line at 0.5px under packages/', () => {
  159. // A separator drawn as a filled box — 1px tall or wide with a border-token
  160. // background (menu separators, the conversation header seam, markdown hr,
  161. // vertical rails) — is the same hairline as a border. Visually-hidden 1px
  162. // clip boxes carry no border-token background and stay exempt.
  163. const wide = packageStylesheets().flatMap(file =>
  164. wideFilledDividers(readFileSync(file, 'utf8')).map(line => `${file} ${line}`))
  165. expect(wide).toEqual([])
  166. })
  167. })